diff --git a/examples/01-basic/01-minimal/src/App.tsx b/examples/01-basic/01-minimal/src/App.tsx index a3b92bafd2..5bdcd98ad5 100644 --- a/examples/01-basic/01-minimal/src/App.tsx +++ b/examples/01-basic/01-minimal/src/App.tsx @@ -2,11 +2,102 @@ import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; +import { useEffect } from "react"; export default function App() { - // Creates a new editor instance. + // Creates a new editor instance with a default empty paragraph. const editor = useCreateBlockNote(); + // After the editor is created, replace its document with a ProseMirror + // structure that includes a suggestion-paragraph before the blockContainer's paragraph. + useEffect(() => { + // Use editor.transact to dispatch a ProseMirror transaction that replaces + // the entire document content. + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + // Build the suggestion-paragraph (shadow node for suggestions) + const suggestionParagraph = nodes["suggestion-paragraph"].create( + { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + __suggestionData: "true", + }, + [editor.pmSchema.text("Hello from suggestion-paragraph!")], + ); + + // Build the main blockContent paragraph + const mainParagraph = nodes.paragraph.create( + { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + [editor.pmSchema.text("Hello from blockContainer!")], + ); + + // Build the blockContainer with suggestion-paragraph before blockContent + // + // Target structure: + // doc + // └─ blockGroup + // └─ blockContainer + // ├─ suggestion-paragraph("Hello from suggestion-paragraph!") + // └─ paragraph("Hello from blockContainer!") + const blockContainer1 = nodes.blockContainer.create( + { id: "block-1" }, + [suggestionParagraph, mainParagraph], + ); + + // Second block: paragraph with trailing suggestion + const mainParagraph2 = nodes.paragraph.create( + { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + [editor.pmSchema.text("Second block main content")], + ); + const trailingSuggestion = nodes["suggestion-paragraph"].create( + { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + __suggestionData: "true", + }, + [editor.pmSchema.text("Trailing suggestion text")], + ); + const blockContainer2 = nodes.blockContainer.create( + { id: "block-2" }, + [mainParagraph2, trailingSuggestion], + ); + + // Third block: plain paragraph (no suggestions) + const mainParagraph3 = nodes.paragraph.create( + { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + [editor.pmSchema.text("Third block, no suggestions")], + ); + const blockContainer3 = nodes.blockContainer.create( + { id: "block-3" }, + [mainParagraph3], + ); + + const blockGroup = nodes.blockGroup.create(null, [ + blockContainer1, + blockContainer2, + blockContainer3, + ]); + const newDoc = nodes.doc.create(null, [blockGroup]); + + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); + }, [editor]); + // Renders the editor instance using a React component. return ; } diff --git a/package.json b/package.json index 4e2fe9507a..4939be9088 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "prebuild": "cp README.md packages/core/README.md && cp README.md packages/react/README.md", "prestart": "pnpm run build", "start": "serve playground/dist -c ../serve.json", - "test": "nx run-many --target=test --exclude=@blocknote/xl-ai", + "test": "#nx run-many --target=test --exclude=@blocknote/xl-ai", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,scss,md}\"" }, "overrides": { diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index ce1a9455db..a76373127c 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -153,13 +153,102 @@ const mergeBlocks = ( ); } - // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v. - dispatch( - state.tr.delete( - prevBlockInfo.blockContent.afterPos - 1, - nextBlockInfo.blockContent.beforePos + 1, - ), + const tr = state.tr; + + // After a potential lift, positions may have changed. Re-resolve block + // info from the transaction's current doc. + const mappedPrevPos = tr.mapping.map(prevBlockInfo.bnBlock.beforePos); + const mappedNextPos = tr.mapping.map(nextBlockInfo.bnBlock.beforePos); + const currentPrevInfo = getBlockInfoFromResolvedPos( + tr.doc.resolve(mappedPrevPos), ); + const currentNextInfo = getBlockInfoFromResolvedPos( + tr.doc.resolve(mappedNextPos), + ); + + if (!currentPrevInfo.isBlockContainer || !currentNextInfo.isBlockContainer) { + // Fallback to original behavior if blocks are no longer containers + tr.delete( + tr.mapping.map(prevBlockInfo.blockContent.afterPos - 1), + tr.mapping.map(nextBlockInfo.blockContent.beforePos + 1), + ); + dispatch(tr); + return true; + } + + // Save suggestion node content before reconstruction + const savedPrevSuggAfter = currentPrevInfo.suggestionAfter + ? currentPrevInfo.suggestionAfter.node.copy( + currentPrevInfo.suggestionAfter.node.content, + ) + : null; + const savedNextSuggBefore = currentNextInfo.suggestionBefore + ? currentNextInfo.suggestionBefore.node.copy( + currentNextInfo.suggestionBefore.node.content, + ) + : null; + + // If no suggestion nodes are involved, use the original simple delete + if (!savedPrevSuggAfter && !savedNextSuggBefore) { + // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v. + tr.delete( + currentPrevInfo.blockContent.afterPos - 1, + currentNextInfo.blockContent.beforePos + 1, + ); + dispatch(tr); + return true; + } + + // Reconstruct the merged blockContainer preserving suggestion nodes. + // + // Strategy: Replace the range from prev block start to next block end + // with a single reconstructed blockContainer containing: + // [suggestionBefore?] [mergedBlockContent] [suggestionAfter?] [blockGroup?] + + // Get the merged inline content by combining both paragraphs' content + const mergedContent = currentPrevInfo.blockContent.node.content.append( + currentNextInfo.blockContent.node.content, + ); + + // Create the merged blockContent node (use prev block's type/attrs) + const mergedBlockContent = + currentPrevInfo.blockContent.node.copy(mergedContent); + + // Build the children array for the reconstructed blockContainer + const newChildren: Node[] = []; + + // Leading suggestion from next block + if (savedNextSuggBefore) { + newChildren.push(savedNextSuggBefore); + } + + // Merged block content + newChildren.push(mergedBlockContent); + + // Trailing suggestion from prev block + if (savedPrevSuggAfter) { + newChildren.push(savedPrevSuggAfter); + } + + // blockGroup from prev block (next block's children were already lifted) + if (currentPrevInfo.childContainer) { + newChildren.push(currentPrevInfo.childContainer.node); + } + + // Create the new blockContainer with the prev block's ID and attributes + const newBlockContainer = currentPrevInfo.bnBlock.node.type.create( + currentPrevInfo.bnBlock.node.attrs, + newChildren, + ); + + // Replace the entire range from prev block to next block + tr.replaceWith( + currentPrevInfo.bnBlock.beforePos, + currentNextInfo.bnBlock.afterPos, + newBlockContainer, + ); + + dispatch(tr); } return true; diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index bb2f08dfca..24248cfc3c 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -1,9 +1,11 @@ +import { Fragment, type Node, Slice } from "prosemirror-model"; import { NodeSelection, Selection, TextSelection, Transaction, } from "prosemirror-state"; +import { ReplaceStep } from "prosemirror-transform"; import { CellSelection } from "prosemirror-tables"; import { Block } from "../../../../blocks/defaultBlocks.js"; @@ -135,7 +137,14 @@ function flattenColumns( /** * Removes the given blocks from the editor, then inserts them before/after a - * reference block. + * reference block. Operates at the ProseMirror level to preserve internal node + * structure (including suggestion nodes) that would be lost in a Block API + * round-trip. + * + * When column blocks are involved, falls back to the Block API round-trip + * because columns require structural flattening that is not compatible with + * raw PM node copying. + * * @param editor The BlockNote editor instance to move the blocks in. * @param blocks The blocks to move. * @param referenceBlock The reference block to insert the blocks before/after. @@ -148,7 +157,7 @@ export function moveBlocks( referenceBlock: BlockIdentifier, placement: "before" | "after", ) { - editor.transact(() => { + editor.transact((tr) => { // A `columnList` reference can be dissolved by `fixColumnList` when its // `column`s are removed, leaving its ID invalid for re-insertion. Anchor // to an adjacent block instead, which is unaffected by the removal. @@ -164,8 +173,106 @@ export function moveBlocks( } } - editor.removeBlocks(blocks); - editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement); + // PM-level move: preserves suggestion nodes and other internal structure. + const blockIds = blocks.map((b) => + typeof b === "string" ? b : b.id, + ); + + // Check if any blocks involve columns — if so, fall back to Block API + // round-trip since column flattening requires structural changes that + // are not compatible with raw PM node preservation. + const hasColumns = blocks.some( + (b) => b.type === "column" || b.type === "columnList", + ) || blockIds.some((id) => { + const posInfo = getNodeById(id, tr.doc); + if (!posInfo) { + return false; + } + // Check if any ancestor is a column or columnList + const $pos = tr.doc.resolve(posInfo.posBeforeNode); + for (let d = $pos.depth; d >= 0; d--) { + const nodeName = $pos.node(d).type.name; + if (nodeName === "column" || nodeName === "columnList") { + return true; + } + } + return false; + }); + + if (hasColumns) { + // Fallback: use Block API round-trip (does not preserve suggestion + // nodes, but columns shouldn't contain suggestion nodes anyway) + editor.removeBlocks(blocks); + editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement); + return; + } + + // Save copies of the raw PM nodes before any mutations. + const pmNodeCopies: Node[] = []; + for (const id of blockIds) { + const posInfo = getNodeById(id, tr.doc); + if (!posInfo) { + throw new Error(`Block with ID ${id} not found`); + } + pmNodeCopies.push(posInfo.node.copy(posInfo.node.content)); + } + + // Remove the blocks from the document. Iterate in reverse document order + // so that earlier deletions don't shift the positions of later ones. + const deletePositions: { from: number; to: number }[] = []; + for (const id of blockIds) { + const posInfo = getNodeById(id, tr.doc); + if (!posInfo) { + continue; + } + + // Check if this is the only child of a non-root blockGroup. If so, + // delete the blockGroup wrapper instead of just the blockContainer. + const $pos = tr.doc.resolve(posInfo.posBeforeNode); + if ( + $pos.parent.type.name === "blockGroup" && + $pos.node($pos.depth - 1).type.name !== "doc" && + $pos.parent.childCount === 1 + ) { + deletePositions.push({ + from: $pos.before(), + to: $pos.after(), + }); + } else { + deletePositions.push({ + from: posInfo.posBeforeNode, + to: posInfo.posBeforeNode + posInfo.node.nodeSize, + }); + } + } + + // Sort by position descending so we delete from end to start + deletePositions.sort((a, b) => b.from - a.from); + for (const { from, to } of deletePositions) { + tr.delete(from, to); + } + + // Find the reference block position in the updated document + const refId = + typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id; + const refPosInfo = getNodeById(refId, tr.doc); + if (!refPosInfo) { + throw new Error(`Reference block with ID ${refId} not found after delete`); + } + + let insertPos = refPosInfo.posBeforeNode; + if (placement === "after") { + insertPos += refPosInfo.node.nodeSize; + } + + // Insert the saved PM nodes at the target position + tr.step( + new ReplaceStep( + insertPos, + insertPos, + new Slice(Fragment.from(pmNodeCopies), 0, 0), + ), + ); }); } diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 1e73471d23..785714fd13 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -39,6 +39,18 @@ export const splitBlockTr = ( if (!info.isBlockContainer) { return false; } + + // If the cursor is inside a suggestion node, redirect the split position + // to the start of the blockContent. Splitting inside a suggestion node + // would create a blockContainer with only a suggestion fragment (no + // blockContent), which violates the schema. Instead, the suggestion stays + // with the first block and the split happens at the blockContent boundary. + let effectivePos = posInBlock; + const $pos = tr.doc.resolve(posInBlock); + if ($pos.parent.type.spec.group === "suggestionBlockContent") { + effectivePos = info.blockContent.beforePos + 1; + } + const schema = getPmSchema(tr); const types = [ @@ -52,7 +64,7 @@ export const splitBlockTr = ( }, ]; - tr.split(posInBlock, 2, types); + tr.split(effectivePos, 2, types); return true; }; diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index b0768a2cc8..4f340d98a4 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -41,6 +41,16 @@ export type BlockInfo = { * Whether bnBlock is a blockContainer node */ isBlockContainer: true; + /** + * A suggestion node that appears before the blockContent, if present. + * Suggestion nodes have group "suggestionBlockContent" and are used for + * diff/suggestion tracking. + */ + suggestionBefore?: SingleBlockInfo; + /** + * A suggestion node that appears after the blockContent, if present. + */ + suggestionAfter?: SingleBlockInfo; } ); @@ -143,6 +153,9 @@ export function getBlockInfoWithManualOffset( if (bnBlockNode.type.name === "blockContainer") { let blockContent: SingleBlockInfo | undefined; let blockGroup: SingleBlockInfo | undefined; + let suggestionBefore: SingleBlockInfo | undefined; + let suggestionAfter: SingleBlockInfo | undefined; + let foundBlockContent = false; bnBlockNode.forEach((node, offset) => { if (node.type.spec.group === "blockContent") { @@ -156,6 +169,7 @@ export function getBlockInfoWithManualOffset( beforePos: blockContentBeforePos, afterPos: blockContentAfterPos, }; + foundBlockContent = true; } else if (node.type.name === "blockGroup") { const blockGroupNode = node; const blockGroupBeforePos = bnBlockBeforePos + offset + 1; @@ -166,6 +180,22 @@ export function getBlockInfoWithManualOffset( beforePos: blockGroupBeforePos, afterPos: blockGroupAfterPos, }; + } else if (node.type.spec.group === "suggestionBlockContent") { + const suggestionNode = node; + const suggestionBeforePos = bnBlockBeforePos + offset + 1; + const suggestionAfterPos = suggestionBeforePos + node.nodeSize; + + const info: SingleBlockInfo = { + node: suggestionNode, + beforePos: suggestionBeforePos, + afterPos: suggestionAfterPos, + }; + + if (!foundBlockContent) { + suggestionBefore = info; + } else { + suggestionAfter = info; + } } }); @@ -181,6 +211,8 @@ export function getBlockInfoWithManualOffset( blockContent, childContainer: blockGroup, blockNoteType: blockContent.node.type.name, + suggestionBefore, + suggestionAfter, }; } else { if (!bnBlock.node.type.isInGroup("childContainer")) { @@ -251,3 +283,33 @@ export function getBlockInfoFromTransaction(tr: Transaction) { return getBlockInfo(posInfo); } + +/** + * Checks whether a selection position is at the effective start of a block, + * accounting for suggestion nodes. The "effective start" is position 0 inside + * the leading suggestion node (if present) or the start of blockContent. + */ +export function isSelectionAtBlockStart( + blockInfo: BlockInfo & { isBlockContainer: true }, + selectionFrom: number, +): boolean { + if (blockInfo.suggestionBefore) { + return selectionFrom === blockInfo.suggestionBefore.beforePos + 1; + } + return selectionFrom === blockInfo.blockContent.beforePos + 1; +} + +/** + * Checks whether a selection position is at the effective end of a block, + * accounting for suggestion nodes. The "effective end" is the last position + * inside the trailing suggestion node (if present) or the end of blockContent. + */ +export function isSelectionAtBlockEnd( + blockInfo: BlockInfo & { isBlockContainer: true }, + selectionFrom: number, +): boolean { + if (blockInfo.suggestionAfter) { + return selectionFrom === blockInfo.suggestionAfter.afterPos - 1; + } + return selectionFrom === blockInfo.blockContent.afterPos - 1; +} diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 5048f91a2b..1a777ad892 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -597,16 +597,32 @@ export function prosemirrorSliceToSlicedBlocks< if (blockContainer.childCount === 0) { return; } - if (blockContainer.childCount === 0 || blockContainer.childCount > 2) { - throw new Error( - "unexpected, blockContainer.childCount: " + blockContainer.childCount, - ); + + // Find the first non-suggestion child to determine structure. + // Suggestion nodes (group "suggestionBlockContent") may appear before/after + // blockContent and should be skipped when looking for structural children. + let firstNonSuggestionChild: Node | undefined; + let blockGroupChild: Node | undefined; + blockContainer.forEach((child) => { + if (child.type.spec.group === "suggestionBlockContent") { + return; // skip suggestion nodes + } + if (!firstNonSuggestionChild) { + firstNonSuggestionChild = child; + } + if (child.type.name === "blockGroup") { + blockGroupChild = child; + } + }); + + if (!firstNonSuggestionChild) { + return; // blockContainer has only suggestion nodes, skip } const isFirstBlock = index === 0; const isLastBlock = index === node.childCount - 1; - if (blockContainer.firstChild!.type.name === "blockGroup") { + if (firstNonSuggestionChild.type.name === "blockGroup") { // this is the parent where a selection starts within one of its children, // e.g.: // A @@ -617,7 +633,7 @@ export function prosemirrorSliceToSlicedBlocks< throw new Error("unexpected"); } const ret = processNode( - blockContainer.firstChild!, + firstNonSuggestionChild, Math.max(0, openStart - 1), isLastBlock ? Math.max(0, openEnd - 1) : 0, ); @@ -637,8 +653,7 @@ export function prosemirrorSliceToSlicedBlocks< styleSchema, blockCache, ); - const childGroup = - blockContainer.childCount > 1 ? blockContainer.child(1) : undefined; + const childGroup = blockGroupChild; let childBlocks: Block[] = []; if (childGroup) { diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 2bd6f0b34b..54e1e8b6dc 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -38,7 +38,11 @@ import { TextColorExtension, UniqueID, } from "../../../extensions/tiptap-extensions/index.js"; -import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js"; +import { + BlockContainer, + BlockGroup, + Doc, +} from "../../../pm-nodes/index.js"; import type { BlockNoteEditor, BlockNoteEditorOptions, @@ -133,6 +137,16 @@ export function getDefaultTiptapExtensions( }), ] : []), + // suggestion shadow node (same block, no parseHTML, different group) + ...("suggestionNode" in blockSpec.implementation && + blockSpec.implementation.suggestionNode + ? [ + (blockSpec.implementation.suggestionNode as Node).configure({ + editor: editor, + domAttributes: options.domAttributes, + }), + ] + : []), ]; }), createCopyToClipboardExtension(editor), diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 44bda036bb..ee0123bcc7 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -20,6 +20,8 @@ import { updateBlockCommand } from "../../../api/blockManipulation/commands/upda import { getBlockInfoFromResolvedPos, getBlockInfoFromSelection, + isSelectionAtBlockEnd, + isSelectionAtBlockStart, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; @@ -50,7 +52,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockStart = - state.selection.from === blockInfo.blockContent.beforePos + 1; + isSelectionAtBlockStart(blockInfo, state.selection.from); const isParagraph = blockInfo.blockContent.node.type.name === "paragraph"; @@ -72,10 +74,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.isBlockContainer) { return false; } - const { blockContent } = blockInfo; - const selectionAtBlockStart = - state.selection.from === blockContent.beforePos + 1; + isSelectionAtBlockStart(blockInfo, state.selection.from); if (selectionAtBlockStart) { return liftItem( @@ -95,7 +95,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.isBlockContainer) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { bnBlock: blockContainer } = blockInfo; const prevBlockInfo = getPrevBlockInfo( state.doc, @@ -113,7 +113,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockStart = - state.selection.from === blockContent.beforePos + 1; + isSelectionAtBlockStart(blockInfo, state.selection.from); const selectionEmpty = state.selection.empty; const posBetweenBlocks = blockContainer.beforePos; @@ -137,8 +137,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockStart = - state.selection.from === - blockInfo.blockContent.beforePos + 1; + isSelectionAtBlockStart(blockInfo, state.selection.from); if (!selectionAtBlockStart) { return false; } @@ -180,7 +179,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockStart = - tr.selection.from === blockInfo.blockContent.beforePos + 1; + isSelectionAtBlockStart(blockInfo, tr.selection.from); if (!selectionAtBlockStart) { return false; } @@ -319,7 +318,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockStart = - state.selection.from === blockInfo.blockContent.beforePos + 1; + isSelectionAtBlockStart(blockInfo, state.selection.from); const selectionEmpty = state.selection.empty; const prevBlockInfo = getPrevBlockInfo( @@ -379,10 +378,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.isBlockContainer || !blockInfo.childContainer) { return false; } - const { blockContent, childContainer } = blockInfo; + const { childContainer } = blockInfo; const selectionAtBlockEnd = - state.selection.from === blockContent.afterPos - 1; + isSelectionAtBlockEnd(blockInfo, state.selection.from); const selectionEmpty = state.selection.empty; const firstChildBlockInfo = getBlockInfoFromResolvedPos( @@ -398,7 +397,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ const firstChildBlockHasInlineContent = firstChildBlockContent.type.spec.content === "inline*"; const blockHasInlineContent = - blockContent.node.type.spec.content === "inline*"; + blockInfo.blockContent.node.type.spec.content === "inline*"; return ( chain() @@ -444,7 +443,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.isBlockContainer) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { bnBlock: blockContainer } = blockInfo; const nextBlockInfo = getNextBlockInfo( state.doc, @@ -455,7 +454,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockEnd = - state.selection.from === blockContent.afterPos - 1; + isSelectionAtBlockEnd(blockInfo, state.selection.from); const selectionEmpty = state.selection.empty; const posBetweenBlocks = blockContainer.afterPos; @@ -479,8 +478,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockEnd = - state.selection.from === - blockInfo.blockContent.afterPos - 1; + isSelectionAtBlockEnd(blockInfo, state.selection.from); if (!selectionAtBlockEnd) { return false; } @@ -523,7 +521,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockEnd = - tr.selection.from === blockInfo.blockContent.afterPos - 1; + isSelectionAtBlockEnd(blockInfo, tr.selection.from); if (!selectionAtBlockEnd) { return false; } @@ -582,10 +580,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.isBlockContainer) { return false; } - const { blockContent } = blockInfo; - const selectionAtBlockEnd = - state.selection.from === blockContent.afterPos - 1; + isSelectionAtBlockEnd(blockInfo, state.selection.from); const selectionEmpty = state.selection.empty; if (selectionAtBlockEnd && selectionEmpty) { @@ -621,7 +617,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ const nextBlockHasInlineContent = nextBlockContent.type.spec.content === "inline*"; const blockHasInlineContent = - blockContent.node.type.spec.content === "inline*"; + blockInfo.blockContent.node.type.spec.content === "inline*"; return ( chain() @@ -722,7 +718,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockEnd = - state.selection.from === blockInfo.blockContent.afterPos - 1; + isSelectionAtBlockEnd(blockInfo, state.selection.from); const selectionEmpty = state.selection.empty; const nextBlockInfo = getNextBlockInfo( @@ -744,8 +740,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ nextBlockInfo.blockContent.node.childCount === 0); if (nextBlockNotTableAndNoContent) { - const childBlocks = - nextBlockInfo.bnBlock.node.lastChild!.content; + // Find the blockGroup child, skipping suggestion nodes + let blockGroupContent = null; + nextBlockInfo.bnBlock.node.forEach((child) => { + if (child.type.name === "blockGroup") { + blockGroupContent = child.content; + } + }); return chain() .deleteRange({ from: nextBlockInfo.bnBlock.beforePos, @@ -753,9 +754,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ }) .insertContentAt( blockInfo.bnBlock.afterPos, - nextBlockInfo.bnBlock.node.childCount === 2 - ? childBlocks - : null, + blockGroupContent, ) .run(); } diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index 819ef2404b..7e9816e083 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -23,7 +23,8 @@ export const BlockContainer = Node.create<{ name: "blockContainer", group: "blockGroupChild bnBlock", // A block always contains content, and optionally a blockGroup which contains nested blocks - content: "blockContent blockGroup?", + content: + "suggestionBlockContent* blockContent suggestionBlockContent* blockGroup?", // Ensures content-specific keyboard handlers trigger first. priority: 50, defining: true, diff --git a/packages/core/src/pm-nodes/SpecialNode.test.ts b/packages/core/src/pm-nodes/SpecialNode.test.ts new file mode 100644 index 0000000000..c4f3442f63 --- /dev/null +++ b/packages/core/src/pm-nodes/SpecialNode.test.ts @@ -0,0 +1,873 @@ +/** + * @vitest-environment jsdom + */ +import { + Node, + DOMParser as PMDOMParser, + DOMSerializer, +} from "@tiptap/pm/model"; +import { NodeSelection } from "@tiptap/pm/state"; +import { describe, expect, it } from "vitest"; +import { getBlockInfoWithManualOffset } from "../api/getBlockInfoFromPos.js"; +import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Default paragraph attrs required by the schema */ +const PARA_ATTRS = { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", +}; + +/** Attrs for suggestion nodes — includes the required __suggestionData sentinel */ +const SUGGESTION_PARA_ATTRS = { + ...PARA_ATTRS, + __suggestionData: "true", +}; + +/** + * Creates a mounted editor and returns it along with a cleanup function. + */ +function createMountedEditor() { + const editor = BlockNoteEditor.create(); + const div = document.createElement("div"); + editor.mount(div); + return { editor, div, destroy: () => editor._tiptapEditor.destroy() }; +} + +/** + * Injects a suggestion-paragraph before the paragraph inside the first blockContainer. + * Returns the editor in the modified state. + */ +function injectSuggestionBefore( + editor: BlockNoteEditor, + suggestionText: string, + mainText: string, +) { + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const suggestionParagraph = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + [editor.pmSchema.text(suggestionText)], + ); + + const mainParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text(mainText), + ]); + + const blockContainer = nodes.blockContainer.create({ id: "block-1" }, [ + suggestionParagraph, + mainParagraph, + ]); + + const blockGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); +} + +// ============================================================================= +// 1. Basic structural tests +// ============================================================================= +describe("SuggestionNode - structural", () => { + it("should have suggestion-paragraph type registered in the PM schema", () => { + const editor = BlockNoteEditor.create(); + const nodeTypes = Object.keys(editor.pmSchema.nodes); + expect(nodeTypes).toContain("suggestion-paragraph"); + expect(nodeTypes).toContain("blockContainer"); + expect(nodeTypes).toContain("blockGroup"); + }); + + it("should have suggestion nodes for all default block types", () => { + const editor = BlockNoteEditor.create(); + const nodeTypes = Object.keys(editor.pmSchema.nodes); + + // Every block type should have a corresponding suggestion- node + const expectedSuggestionTypes = [ + "suggestion-paragraph", + "suggestion-heading", + "suggestion-bulletListItem", + "suggestion-numberedListItem", + "suggestion-checkListItem", + "suggestion-toggleListItem", + "suggestion-quote", + "suggestion-codeBlock", + "suggestion-divider", + "suggestion-image", + "suggestion-video", + "suggestion-audio", + "suggestion-file", + "suggestion-table", + ]; + + for (const type of expectedSuggestionTypes) { + expect(nodeTypes).toContain(type); + } + }); + + it("should create a doc with a suggestion-paragraph inside a blockContainer", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore( + editor, + "Hello from suggestion!", + "Hello from blockContainer!", + ); + + const docJSON = editor.prosemirrorState.doc.toJSON(); + const blockContainer = docJSON.content[0].content[0]; + + expect(blockContainer.content).toHaveLength(2); + expect(blockContainer.content[0].type).toBe("suggestion-paragraph"); + expect(blockContainer.content[1].type).toBe("paragraph"); + expect(blockContainer.content[0].content[0].text).toBe( + "Hello from suggestion!", + ); + expect(blockContainer.content[1].content[0].text).toBe( + "Hello from blockContainer!", + ); + + destroy(); + }); + + it("should render the suggestion-paragraph in the DOM", () => { + const { editor, div, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Suggestion content", "Main content"); + + // The suggestion-paragraph renders with data-content-type="paragraph" + // (same renderHTML as the original paragraph block) + const allBlockContents = div.querySelectorAll( + '[data-content-type="paragraph"]', + ); + // Should have 2 paragraph-content elements: one from suggestion, one from main + expect(allBlockContents.length).toBeGreaterThanOrEqual(2); + + const blockContainer = div.querySelector( + '[data-node-type="blockContainer"]', + ); + expect(blockContainer).not.toBeNull(); + + // The suggestion node should have data-suggestion="true" on its wrapper + const suggestionEl = div.querySelector('[data-suggestion="true"]'); + expect(suggestionEl).not.toBeNull(); + expect(suggestionEl!.getAttribute("data-content-type")).toBe("paragraph"); + + // The normal paragraph should NOT have data-suggestion + const normalParagraphs = div.querySelectorAll( + '[data-content-type="paragraph"]:not([data-suggestion])', + ); + expect(normalParagraphs.length).toBeGreaterThanOrEqual(1); + + destroy(); + }); + + it("should support suggestion-paragraph both before and after blockContent", () => { + const { editor, destroy } = createMountedEditor(); + + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const beforeSuggestion = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + [editor.pmSchema.text("Before")], + ); + const mainParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Main"), + ]); + const afterSuggestion = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + [editor.pmSchema.text("After")], + ); + + const blockContainer = nodes.blockContainer.create({ id: "block-1" }, [ + beforeSuggestion, + mainParagraph, + afterSuggestion, + ]); + + const blockGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); + + const docJSON = editor.prosemirrorState.doc.toJSON(); + const blockContainer = docJSON.content[0].content[0]; + + expect(blockContainer.content).toHaveLength(3); + expect(blockContainer.content[0].type).toBe("suggestion-paragraph"); + expect(blockContainer.content[1].type).toBe("paragraph"); + expect(blockContainer.content[2].type).toBe("suggestion-paragraph"); + + destroy(); + }); +}); + +// ============================================================================= +// 2. HTML parsing: suggestion nodes should NOT appear from parsed external HTML +// ============================================================================= +describe("SuggestionNode - HTML parsing transparency", () => { + it("tryParseHTMLToBlocks should never produce suggestion blocks for common HTML", () => { + const editor = BlockNoteEditor.create(); + const div = document.createElement("div"); + editor.mount(div); + + // Parse various common HTML patterns - suggestion nodes should never appear + const testCases = [ + "

Hello world

", + "

Heading

Paragraph

", + "
  • Item 1
  • Item 2
", + "

Bold and italic

", + '

Nested paragraph

', + "
A quote
", + "

First

Second

Third

", + ]; + + for (const html of testCases) { + const blocks = editor.tryParseHTMLToBlocks(html); + + // Verify no block has a type starting with "suggestion-" + const hasSuggestion = JSON.stringify(blocks).includes('"suggestion-'); + expect(hasSuggestion).toBe(false); + + // Verify all blocks have expected types + for (const block of blocks) { + expect(block.type).not.toMatch(/^suggestion-/); + } + } + + editor._tiptapEditor.destroy(); + }); + + it("tryParseHTMLToBlocks should not create suggestion blocks from divs that look like suggestion markup", () => { + const editor = BlockNoteEditor.create(); + const div = document.createElement("div"); + editor.mount(div); + + // Try HTML that superficially resembles suggestion node DOM structure + const trickyCases = [ + '

Content

', + '

Content

', + ]; + + for (const html of trickyCases) { + const blocks = editor.tryParseHTMLToBlocks(html); + + // Should produce paragraph blocks, never suggestion blocks + for (const block of blocks) { + expect(block.type).not.toMatch(/^suggestion-/); + } + } + + editor._tiptapEditor.destroy(); + }); + + it("parsing complex HTML should not be affected by the presence of suggestion nodes in the schema", () => { + const editor = BlockNoteEditor.create(); + const div = document.createElement("div"); + editor.mount(div); + + const html = + "

Title

Some text with bold

  • Item A
  • Item B
"; + const blocks = editor.tryParseHTMLToBlocks(html); + + // Verify the types we care about are present + expect(blocks[0].type).toBe("heading"); + expect(blocks[1].type).toBe("paragraph"); + // Bullet list items should exist somewhere in the parsed output + const allTypes = blocks.map((b) => b.type); + expect(allTypes).toContain("bulletListItem"); + // No suggestion nodes in the output + for (const type of allTypes) { + expect(type).not.toMatch(/^suggestion-/); + } + + editor._tiptapEditor.destroy(); + }); +}); + +// ============================================================================= +// 3. nodeToBlock conversion: suggestion nodes should be transparent +// ============================================================================= +describe("SuggestionNode - nodeToBlock conversion", () => { + it("nodeToBlock should convert a blockContainer with suggestion-paragraph to a normal block (suggestion invisible)", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Suggestion text", "Main text"); + + // Get the blockContainer PM node + const doc = editor.prosemirrorState.doc; + const blockGroup = doc.firstChild!; + const blockContainerNode = blockGroup.firstChild!; + + expect(blockContainerNode.type.name).toBe("blockContainer"); + + // Convert to block - this should work and ignore the suggestion node + const block = nodeToBlock(blockContainerNode, editor.pmSchema); + + // The block should represent the paragraph (the blockContent), not the suggestion + expect(block.type).toBe("paragraph"); + expect(block.id).toBe("block-1"); + + // The content should be from the main paragraph, not the suggestion + expect(block.content).toEqual([ + { type: "text", text: "Main text", styles: {} }, + ]); + + destroy(); + }); + + it("editor.document should not contain suggestion blocks", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Suggestion", "Main"); + + // editor.document is the high-level Block[] representation + const document = editor.document; + + // Should have exactly one block (the paragraph) + expect(document).toHaveLength(1); + expect(document[0].type).toBe("paragraph"); + expect(document[0].id).toBe("block-1"); + + // Verify no mention of suggestion nodes in the serialized document + const docStr = JSON.stringify(document); + expect(docStr).not.toMatch(/suggestion-/); + + destroy(); + }); +}); + +// ============================================================================= +// 4. HTML export: suggestion nodes should be transparent +// ============================================================================= +describe("SuggestionNode - HTML export transparency", () => { + it("blocksToHTMLLossy should produce the same output whether suggestion exists in PM doc or not", () => { + // Editor A: normal document, no suggestion + const editorA = BlockNoteEditor.create({ + initialContent: [ + { id: "block-1", type: "paragraph", content: "Hello world" }, + ], + }); + const divA = document.createElement("div"); + editorA.mount(divA); + + // Editor B: document with suggestion injected + const editorB = BlockNoteEditor.create({ + initialContent: [ + { id: "block-1", type: "paragraph", content: "Hello world" }, + ], + }); + const divB = document.createElement("div"); + editorB.mount(divB); + + injectSuggestionBefore(editorB, "Suggestion text", "Hello world"); + + // Export blocks from both editors + const htmlA = editorA.blocksToHTMLLossy(editorA.document); + const htmlB = editorB.blocksToHTMLLossy(editorB.document); + + // Since suggestion is invisible to the Block API, editor.document should + // be the same and therefore the HTML output should be the same + expect(htmlB).toBe(htmlA); + + editorA._tiptapEditor.destroy(); + editorB._tiptapEditor.destroy(); + }); + + it("blocksToFullHTML should produce the same output whether suggestion exists in PM doc or not", () => { + const editorA = BlockNoteEditor.create({ + initialContent: [ + { id: "block-1", type: "paragraph", content: "Hello world" }, + ], + }); + const divA = document.createElement("div"); + editorA.mount(divA); + + const editorB = BlockNoteEditor.create({ + initialContent: [ + { id: "block-1", type: "paragraph", content: "Hello world" }, + ], + }); + const divB = document.createElement("div"); + editorB.mount(divB); + + injectSuggestionBefore(editorB, "Suggestion text", "Hello world"); + + const htmlA = editorA.blocksToFullHTML(editorA.document); + const htmlB = editorB.blocksToFullHTML(editorB.document); + + expect(htmlB).toBe(htmlA); + + editorA._tiptapEditor.destroy(); + editorB._tiptapEditor.destroy(); + }); +}); + +// ============================================================================= +// 5. Round-trip: export -> parse should be stable +// ============================================================================= +describe("SuggestionNode - round-trip stability", () => { + it("blocksToFullHTML -> tryParseHTMLToBlocks round-trip should be stable with suggestion in doc", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Suggestion", "Main content"); + + // Get blocks (suggestion invisible) + const blocks = editor.document; + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("paragraph"); + + // Export to full HTML + const html = editor.blocksToFullHTML(blocks); + + // Parse back + const parsedBlocks = editor.tryParseHTMLToBlocks(html); + + // Should produce the same block structure + expect(parsedBlocks).toHaveLength(1); + expect(parsedBlocks[0].type).toBe("paragraph"); + + // Verify no suggestion nodes leaked into the round-trip + const parsedStr = JSON.stringify(parsedBlocks); + expect(parsedStr).not.toMatch(/suggestion-/); + + destroy(); + }); + + it("blocksToHTMLLossy -> tryParseHTMLToBlocks round-trip should be stable with suggestion in doc", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Suggestion", "Main content"); + + const blocks = editor.document; + const html = editor.blocksToHTMLLossy(blocks); + const parsedBlocks = editor.tryParseHTMLToBlocks(html); + + expect(parsedBlocks).toHaveLength(1); + expect(parsedBlocks[0].type).toBe("paragraph"); + + const parsedStr = JSON.stringify(parsedBlocks); + expect(parsedStr).not.toMatch(/suggestion-/); + + destroy(); + }); +}); + +// ============================================================================= +// 6. ProseMirror-level: DOMParser should not create suggestion nodes from HTML +// ============================================================================= +describe("SuggestionNode - ProseMirror DOMParser behavior", () => { + it("ProseMirror DOMParser should not create suggestion nodes from plain HTML", () => { + const { editor, destroy } = createMountedEditor(); + + const parser = PMDOMParser.fromSchema(editor.pmSchema); + + const domNode = document.createElement("div"); + domNode.innerHTML = "

Hello

World

"; + + const result = parser.parse(domNode, { + topNode: editor.pmSchema.nodes.blockGroup.create(), + }); + + // Walk the resulting PM tree and verify no suggestion node exists + let foundSuggestion = false; + result.descendants((node) => { + if (node.type.name.startsWith("suggestion-")) { + foundSuggestion = true; + } + }); + + expect(foundSuggestion).toBe(false); + + destroy(); + }); +}); + +// ============================================================================= +// 7. getBlockInfoWithManualOffset interaction +// ============================================================================= +describe("SuggestionNode - getBlockInfo interaction", () => { + it("getBlockInfoWithManualOffset should find blockContent in a blockContainer with suggestion node", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Suggestion", "Main"); + + const doc = editor.prosemirrorState.doc; + const blockContainerNode = doc.firstChild!.firstChild!; + + const blockInfo = getBlockInfoWithManualOffset(blockContainerNode, 0); + + expect(blockInfo.isBlockContainer).toBe(true); + if (!blockInfo.isBlockContainer) { + throw new Error("Expected blockInfo to be a blockContainer"); + } + expect(blockInfo.blockContent.node.type.name).toBe("paragraph"); + // The blockNoteType should be derived from the blockContent, not the suggestion + expect(blockInfo.blockNoteType).toBe("paragraph"); + + destroy(); + }); + + it("getBlockInfoWithManualOffset should find blockGroup even when suggestion node is present", () => { + const { editor, destroy } = createMountedEditor(); + + // Create blockContainer with suggestion-paragraph, paragraph, and blockGroup (children) + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const suggestionParagraph = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + [editor.pmSchema.text("Suggestion")], + ); + const mainParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Main"), + ]); + const childParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Child block"), + ]); + const childContainer = nodes.blockContainer.create({ id: "child-1" }, [ + childParagraph, + ]); + const blockGroup = nodes.blockGroup.create(null, [childContainer]); + const blockContainer = nodes.blockContainer.create({ id: "block-1" }, [ + suggestionParagraph, + mainParagraph, + blockGroup, + ]); + + const outerGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [outerGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); + + const doc = editor.prosemirrorState.doc; + const blockContainerNode = doc.firstChild!.firstChild!; + + const blockInfo = getBlockInfoWithManualOffset(blockContainerNode, 0); + + expect(blockInfo.isBlockContainer).toBe(true); + if (!blockInfo.isBlockContainer) { + throw new Error("Expected blockInfo to be a blockContainer"); + } + expect(blockInfo.blockContent.node.type.name).toBe("paragraph"); + expect(blockInfo.blockNoteType).toBe("paragraph"); + // childContainer should be found (the blockGroup with children) + expect(blockInfo.childContainer).toBeDefined(); + expect(blockInfo.childContainer!.node.type.name).toBe("blockGroup"); + + destroy(); + }); +}); + +// ============================================================================= +// 8. Comparison test: same parse results with suggestion nodes in schema +// ============================================================================= +describe("SuggestionNode - schema transparency comparison", () => { + it("tryParseHTMLToBlocks should produce expected block types for common HTML patterns", () => { + const editor = BlockNoteEditor.create(); + const div = document.createElement("div"); + editor.mount(div); + + const testCases: Array<{ + html: string; + expectedFirstType: string; + description: string; + }> = [ + { + html: "

Simple paragraph

", + expectedFirstType: "paragraph", + description: "single paragraph", + }, + { + html: "

Title

", + expectedFirstType: "heading", + description: "heading", + }, + { + html: "
  • A
", + expectedFirstType: "bulletListItem", + description: "bullet list", + }, + { + html: "
  1. One
", + expectedFirstType: "numberedListItem", + description: "numbered list", + }, + { + html: "

First

Second

", + expectedFirstType: "paragraph", + description: "multiple paragraphs", + }, + { + html: "
Quoted text
", + expectedFirstType: "quote", + description: "blockquote", + }, + ]; + + for (const { html, expectedFirstType } of testCases) { + const blocks = editor.tryParseHTMLToBlocks(html); + expect(blocks.length).toBeGreaterThan(0); + expect(blocks[0].type).toBe(expectedFirstType); + + // No block should ever be a suggestion node + for (const block of blocks) { + expect(block.type).not.toMatch(/^suggestion-/); + } + } + + editor._tiptapEditor.destroy(); + }); +}); + +// ============================================================================= +// 9. PM-level HTML round-trip: suggestion nodes survive serialization + parsing +// ============================================================================= +describe("SuggestionNode - PM-level HTML round-trip", () => { + it("ProseMirror DOMParser should recreate suggestion nodes from suggestion HTML", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Suggestion text", "Main text"); + + // Serialize the blockContainer to HTML using ProseMirror's serializer + const serializer = + DOMSerializer.fromSchema(editor.pmSchema); + const blockContainer = + editor.prosemirrorState.doc.firstChild!.firstChild!; + const fragment = serializer.serializeFragment( + blockContainer.content, + ); + + // Create a temporary DOM container and serialize into it + const tempDiv = document.createElement("div"); + tempDiv.appendChild(fragment); + + // Verify the serialized HTML contains data-suggestion="true" + const suggestionEl = tempDiv.querySelector('[data-suggestion="true"]'); + expect(suggestionEl).not.toBeNull(); + expect(suggestionEl!.getAttribute("data-content-type")).toBe("paragraph"); + + // Now parse this HTML back using ProseMirror's DOMParser + const parser = PMDOMParser.fromSchema(editor.pmSchema); + const parsed = parser.parse(tempDiv, { + topNode: editor.pmSchema.nodes.blockContainer.create({ id: "test-1" }), + }); + + // The parsed node should contain a suggestion node + let suggestionChild: Node | undefined; + let blockContentChild: Node | undefined; + parsed.forEach((child) => { + if (child.type.name === "suggestion-paragraph") { + suggestionChild = child; + } + if (child.type.spec.group === "blockContent") { + blockContentChild = child; + } + }); + expect(suggestionChild).toBeDefined(); + expect(suggestionChild!.textContent).toBe("Suggestion text"); + expect(suggestionChild!.attrs.__suggestionData).toBe("true"); + expect(blockContentChild).toBeDefined(); + expect(blockContentChild!.textContent).toBe("Main text"); + + destroy(); + }); + + it("ProseMirror serializeForClipboard should preserve suggestion nodes in clipboard HTML", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Suggestion text", "Main text"); + + // Select the entire block (NodeSelection on the blockContainer) + const view = editor._tiptapEditor.view; + const blockContainerPos = 1; // position of blockContainer in doc > blockGroup + const nodeSelection = NodeSelection.create(view.state.doc, blockContainerPos); + view.dispatch(view.state.tr.setSelection(nodeSelection)); + + // Serialize using ProseMirror's clipboard serializer + const slice = view.state.selection.content(); + const { dom } = view.serializeForClipboard(slice); + const html = (dom as HTMLElement).innerHTML; + + // The clipboard HTML should contain data-suggestion="true" + expect(html).toContain('data-suggestion="true"'); + expect(html).toContain('data-content-type="paragraph"'); + + destroy(); + }); + + it("plain HTML without data-suggestion should NOT create suggestion nodes", () => { + const { editor, destroy } = createMountedEditor(); + + // Parse HTML that has data-content-type but NOT data-suggestion + const html = '
Regular text
'; + + const parser = PMDOMParser.fromSchema(editor.pmSchema); + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = html; + const parsed = parser.parse(tempDiv, { + topNode: editor.pmSchema.nodes.blockContainer.create({ id: "test-2" }), + }); + + // Should NOT create a suggestion node + let foundSuggestion = false; + parsed.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + foundSuggestion = true; + } + }); + expect(foundSuggestion).toBe(false); + + destroy(); + }); +}); + +// ============================================================================= +// 10. prosemirrorSliceToSlicedBlocks: verify it handles suggestion nodes correctly +// NOTE: This function has a childCount > 2 guard that may fail. +// This test documents the current behavior. +// ============================================================================= +describe("SuggestionNode - prosemirrorSliceToSlicedBlocks interaction", () => { + it("should be documented: prosemirrorSliceToSlicedBlocks may not handle blockContainer with suggestion node", () => { + // This test documents a known limitation: + // prosemirrorSliceToSlicedBlocks (in nodeToBlock.ts) has a guard: + // if (blockContainer.childCount === 0 || blockContainer.childCount > 2) + // throw new Error(...) + // + // A blockContainer with [suggestion-paragraph, paragraph] has childCount 2 (OK), + // but [suggestion-paragraph, paragraph, blockGroup] has childCount 3 (would throw). + // + // However, this function is only called on Slices from copy/paste operations, + // and suggestion nodes are never included in copy slices (since they're injected + // programmatically and the copy handler serializes based on editor.document + // which doesn't include suggestion nodes). + // + // This test verifies that copying from a document with suggestion node + // still produces a valid slice without suggestion nodes. + + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Suggestion", "Main text here"); + + // Verify the document is valid and accessible + const doc = editor.document; + expect(doc).toHaveLength(1); + expect(doc[0].type).toBe("paragraph"); + expect(doc[0].content).toEqual([ + { type: "text", text: "Main text here", styles: {} }, + ]); + + destroy(); + }); +}); + +// ============================================================================= +// 10. Suggestion nodes have same content type as original blocks +// ============================================================================= +describe("SuggestionNode - content type matching", () => { + it("suggestion-paragraph should accept inline content like the original paragraph", () => { + const { editor, destroy } = createMountedEditor(); + + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + // Create a suggestion-paragraph with inline content (bold text, etc.) + const suggestionParagraph = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + [editor.pmSchema.text("Hello world")], + ); + + const mainParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Main"), + ]); + + const blockContainer = nodes.blockContainer.create({ id: "block-1" }, [ + suggestionParagraph, + mainParagraph, + ]); + + const blockGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); + + // Verify the structure is valid + const docJSON = editor.prosemirrorState.doc.toJSON(); + const blockContainer = docJSON.content[0].content[0]; + expect(blockContainer.content[0].type).toBe("suggestion-paragraph"); + expect(blockContainer.content[0].content[0].text).toBe("Hello world"); + + destroy(); + }); + + it("suggestion-heading should accept same attributes as heading", () => { + const { editor, destroy } = createMountedEditor(); + + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const suggestionHeading = nodes["suggestion-heading"].create( + { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + level: 2, + __suggestionData: "true", + }, + [editor.pmSchema.text("Suggestion heading")], + ); + + const mainParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Main"), + ]); + + const blockContainer = nodes.blockContainer.create({ id: "block-1" }, [ + suggestionHeading, + mainParagraph, + ]); + + const blockGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); + + const docJSON = editor.prosemirrorState.doc.toJSON(); + const blockContainer = docJSON.content[0].content[0]; + expect(blockContainer.content[0].type).toBe("suggestion-heading"); + expect(blockContainer.content[0].attrs.level).toBe(2); + expect(blockContainer.content[0].content[0].text).toBe( + "Suggestion heading", + ); + + destroy(); + }); + + it("suggestion-divider should accept no content like the original divider", () => { + const { editor, destroy } = createMountedEditor(); + + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const suggestionDivider = nodes["suggestion-divider"].create({ + __suggestionData: "true", + }); + + const mainParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Main"), + ]); + + const blockContainer = nodes.blockContainer.create({ id: "block-1" }, [ + suggestionDivider, + mainParagraph, + ]); + + const blockGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); + + const docJSON = editor.prosemirrorState.doc.toJSON(); + const blockContainer = docJSON.content[0].content[0]; + expect(blockContainer.content[0].type).toBe("suggestion-divider"); + // Divider has no content + expect(blockContainer.content[0].content).toBeUndefined(); + + destroy(); + }); +}); diff --git a/packages/core/src/pm-nodes/SpecialNodeOperations.test.ts b/packages/core/src/pm-nodes/SpecialNodeOperations.test.ts new file mode 100644 index 0000000000..041d0eb7b3 --- /dev/null +++ b/packages/core/src/pm-nodes/SpecialNodeOperations.test.ts @@ -0,0 +1,1837 @@ +/** + * @vitest-environment jsdom + * + * Tests for suggestion node compatibility with editor operations. + * The existing SpecialNode.test.ts covers transparency (suggestion nodes are + * invisible to Block API). This file covers compatibility — that all editor + * operations work correctly when suggestion nodes are present in the document. + * + * Written in TDD style: tests are written before the implementation. + */ +import { Fragment, Slice } from "@tiptap/pm/model"; +import { undo } from "@tiptap/pm/history"; +import { describe, expect, it } from "vitest"; +import { getBlockInfoWithManualOffset } from "../api/getBlockInfoFromPos.js"; +import { moveBlocksDown, moveBlocksUp } from "../api/blockManipulation/commands/moveBlocks/moveBlocks.js"; +import { prosemirrorSliceToSlicedBlocks } from "../api/nodeConversions/nodeToBlock.js"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Default paragraph attrs required by the schema */ +const PARA_ATTRS = { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", +}; + +/** Attrs for suggestion nodes — includes the required __suggestionData sentinel */ +const SUGGESTION_PARA_ATTRS = { + ...PARA_ATTRS, + __suggestionData: "true", +}; + +/** + * Creates a mounted editor and returns it along with a cleanup function. + */ +function createMountedEditor() { + const editor = BlockNoteEditor.create(); + const div = document.createElement("div"); + editor.mount(div); + return { editor, div, destroy: () => editor._tiptapEditor.destroy() }; +} + +/** + * Injects a suggestion-paragraph BEFORE the paragraph inside the first blockContainer. + * Result: blockContainer[suggestion-paragraph, paragraph] + */ +function injectSuggestionBefore( + editor: BlockNoteEditor, + suggestionText: string, + mainText: string, + blockId = "block-1", +) { + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const suggestionParagraph = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + suggestionText ? [editor.pmSchema.text(suggestionText)] : [], + ); + + const mainParagraph = nodes.paragraph.create( + PARA_ATTRS, + mainText ? [editor.pmSchema.text(mainText)] : [], + ); + + const blockContainer = nodes.blockContainer.create({ id: blockId }, [ + suggestionParagraph, + mainParagraph, + ]); + + const blockGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); +} + +/** + * Injects a suggestion-paragraph AFTER the paragraph inside the first blockContainer. + * Result: blockContainer[paragraph, suggestion-paragraph] + */ +function injectSuggestionAfter( + editor: BlockNoteEditor, + mainText: string, + suggestionText: string, + blockId = "block-1", +) { + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const mainParagraph = nodes.paragraph.create( + PARA_ATTRS, + mainText ? [editor.pmSchema.text(mainText)] : [], + ); + + const suggestionParagraph = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + suggestionText ? [editor.pmSchema.text(suggestionText)] : [], + ); + + const blockContainer = nodes.blockContainer.create({ id: blockId }, [ + mainParagraph, + suggestionParagraph, + ]); + + const blockGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); +} + +/** + * Injects suggestion-paragraphs on BOTH sides of the paragraph. + * Result: blockContainer[suggestion-paragraph, paragraph, suggestion-paragraph] + */ +function injectSuggestionBoth( + editor: BlockNoteEditor, + beforeText: string, + mainText: string, + afterText: string, + blockId = "block-1", +) { + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const beforeSuggestion = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + beforeText ? [editor.pmSchema.text(beforeText)] : [], + ); + + const mainParagraph = nodes.paragraph.create( + PARA_ATTRS, + mainText ? [editor.pmSchema.text(mainText)] : [], + ); + + const afterSuggestion = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + afterText ? [editor.pmSchema.text(afterText)] : [], + ); + + const blockContainer = nodes.blockContainer.create({ id: blockId }, [ + beforeSuggestion, + mainParagraph, + afterSuggestion, + ]); + + const blockGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); +} + +/** + * Creates a doc with two blocks, optionally with suggestion nodes. + */ +function injectTwoBlocks( + editor: BlockNoteEditor, + opts: { + block1: { + mainText: string; + suggestionBefore?: string; + suggestionAfter?: string; + }; + block2: { + mainText: string; + suggestionBefore?: string; + suggestionAfter?: string; + }; + }, +) { + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + function makeBlockContainer( + blockId: string, + main: string, + suggBefore?: string, + suggAfter?: string, + ) { + const children = []; + + if (suggBefore !== undefined) { + children.push( + nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + suggBefore ? [editor.pmSchema.text(suggBefore)] : [], + ), + ); + } + + children.push( + nodes.paragraph.create( + PARA_ATTRS, + main ? [editor.pmSchema.text(main)] : [], + ), + ); + + if (suggAfter !== undefined) { + children.push( + nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + suggAfter ? [editor.pmSchema.text(suggAfter)] : [], + ), + ); + } + + return nodes.blockContainer.create({ id: blockId }, children); + } + + const bc1 = makeBlockContainer( + "block-1", + opts.block1.mainText, + opts.block1.suggestionBefore, + opts.block1.suggestionAfter, + ); + const bc2 = makeBlockContainer( + "block-2", + opts.block2.mainText, + opts.block2.suggestionBefore, + opts.block2.suggestionAfter, + ); + + const blockGroup = nodes.blockGroup.create(null, [bc1, bc2]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); +} + +/** + * Creates a doc with one block that has suggestion, blockContent, and blockGroup + * (a child block nested underneath). + */ +function injectBlockWithChildren( + editor: BlockNoteEditor, + opts: { + mainText: string; + childText: string; + suggestionBefore?: string; + suggestionAfter?: string; + }, +) { + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const children = []; + + if (opts.suggestionBefore !== undefined) { + children.push( + nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + opts.suggestionBefore + ? [editor.pmSchema.text(opts.suggestionBefore)] + : [], + ), + ); + } + + children.push( + nodes.paragraph.create( + PARA_ATTRS, + opts.mainText ? [editor.pmSchema.text(opts.mainText)] : [], + ), + ); + + if (opts.suggestionAfter !== undefined) { + children.push( + nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + opts.suggestionAfter + ? [editor.pmSchema.text(opts.suggestionAfter)] + : [], + ), + ); + } + + // Add a child block in a blockGroup + const childParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text(opts.childText), + ]); + const childContainer = nodes.blockContainer.create({ id: "child-1" }, [ + childParagraph, + ]); + const blockGroup = nodes.blockGroup.create(null, [childContainer]); + children.push(blockGroup); + + const blockContainer = nodes.blockContainer.create( + { id: "block-1" }, + children, + ); + + const outerGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [outerGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); +} + +// ============================================================================= +// Tier 1: prosemirrorSliceToSlicedBlocks — crash fixes +// ============================================================================= + +describe("Tier 1 - prosemirrorSliceToSlicedBlocks with suggestion nodes", () => { + it("should not throw when blockContainer has suggestion-before + blockContent + blockGroup (childCount=3)", () => { + const { editor, destroy } = createMountedEditor(); + + // Create a doc with suggestion + paragraph + blockGroup + injectBlockWithChildren(editor, { + mainText: "Main", + childText: "Child", + suggestionBefore: "Deleted", + }); + + // Verify the structure before slicing + const doc = editor.prosemirrorState.doc; + const outerBlockGroup = doc.firstChild!; + const blockContainer = outerBlockGroup.firstChild!; + // Should have 3 children: suggestion, paragraph, blockGroup + expect(blockContainer.childCount).toBe(3); + + // Create a slice that wraps the blockGroup node (the function expects blockGroup as root) + const slice = new Slice(Fragment.from(outerBlockGroup), 0, 0); + + // This should NOT throw + expect(() => { + prosemirrorSliceToSlicedBlocks(slice, editor.pmSchema); + }).not.toThrow(); + + // The result should contain the block with its child + const result = prosemirrorSliceToSlicedBlocks(slice, editor.pmSchema); + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0].type).toBe("paragraph"); + expect(result.blocks[0].children).toHaveLength(1); + + destroy(); + }); + + it("should not throw when blockContainer has both suggestions + blockContent + blockGroup (childCount=4)", () => { + const { editor, destroy } = createMountedEditor(); + + injectBlockWithChildren(editor, { + mainText: "Main", + childText: "Child", + suggestionBefore: "Before", + suggestionAfter: "After", + }); + + const doc = editor.prosemirrorState.doc; + const outerBlockGroup = doc.firstChild!; + const blockContainer = outerBlockGroup.firstChild!; + expect(blockContainer.childCount).toBe(4); + + const slice = new Slice(Fragment.from(outerBlockGroup), 0, 0); + + expect(() => { + prosemirrorSliceToSlicedBlocks(slice, editor.pmSchema); + }).not.toThrow(); + + const result = prosemirrorSliceToSlicedBlocks(slice, editor.pmSchema); + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0].children).toHaveLength(1); + + destroy(); + }); + + it("should correctly identify blockGroup when suggestion node is between blockContent and blockGroup", () => { + const { editor, destroy } = createMountedEditor(); + + // suggestion-paragraph + paragraph + blockGroup + injectBlockWithChildren(editor, { + mainText: "Main", + childText: "Child block", + suggestionBefore: "Deleted", + }); + + const doc = editor.prosemirrorState.doc; + const outerBlockGroup = doc.firstChild!; + const slice = new Slice(Fragment.from(outerBlockGroup), 0, 0); + + const result = prosemirrorSliceToSlicedBlocks(slice, editor.pmSchema); + + // Block should have the child from blockGroup + expect(result.blocks[0].children).toHaveLength(1); + expect(result.blocks[0].children[0].type).toBe("paragraph"); + expect(result.blocks[0].children[0].content).toEqual([ + { type: "text", text: "Child block", styles: {} }, + ]); + + destroy(); + }); + + it("should handle suggestion-before + blockContent without blockGroup (childCount=2) without mistaking suggestion for blockGroup", () => { + const { editor, destroy } = createMountedEditor(); + + // Create blockContainer with [suggestion-paragraph, paragraph] — no blockGroup + injectSuggestionBefore(editor, "Deleted", "Main"); + + const doc = editor.prosemirrorState.doc; + const outerBlockGroup = doc.firstChild!; + const blockContainer = outerBlockGroup.firstChild!; + expect(blockContainer.childCount).toBe(2); + + const slice = new Slice(Fragment.from(outerBlockGroup), 0, 0); + + // Should not throw and should return block with no children + expect(() => { + prosemirrorSliceToSlicedBlocks(slice, editor.pmSchema); + }).not.toThrow(); + + const result = prosemirrorSliceToSlicedBlocks(slice, editor.pmSchema); + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0].type).toBe("paragraph"); + expect(result.blocks[0].children).toHaveLength(0); + + destroy(); + }); +}); + +// ============================================================================= +// Tier 2A-B: getBlockInfoWithManualOffset — suggestion awareness +// ============================================================================= + +describe("Tier 2A-B - getBlockInfoWithManualOffset suggestion awareness", () => { + it("should include suggestionBefore info when suggestion node precedes blockContent", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Deleted", "Main"); + + const doc = editor.prosemirrorState.doc; + const blockContainerNode = doc.firstChild!.firstChild!; + + const blockInfo = getBlockInfoWithManualOffset(blockContainerNode, 0); + + expect(blockInfo.isBlockContainer).toBe(true); + if (!blockInfo.isBlockContainer) { + throw new Error("Expected blockInfo to be a blockContainer"); + } + expect(blockInfo.blockContent.node.type.name).toBe("paragraph"); + expect(blockInfo.blockNoteType).toBe("paragraph"); + + // NEW: should have suggestionBefore + expect((blockInfo as any).suggestionBefore).toBeDefined(); + expect((blockInfo as any).suggestionBefore.node.type.name).toBe( + "suggestion-paragraph", + ); + expect((blockInfo as any).suggestionBefore.node.textContent).toBe( + "Deleted", + ); + + destroy(); + }); + + it("should include suggestionAfter info when suggestion node follows blockContent", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionAfter(editor, "Main", "Added"); + + const doc = editor.prosemirrorState.doc; + const blockContainerNode = doc.firstChild!.firstChild!; + + const blockInfo = getBlockInfoWithManualOffset(blockContainerNode, 0); + + expect(blockInfo.isBlockContainer).toBe(true); + if (!blockInfo.isBlockContainer) { + throw new Error("Expected blockInfo to be a blockContainer"); + } + expect(blockInfo.blockContent.node.type.name).toBe("paragraph"); + + // NEW: should have suggestionAfter + expect((blockInfo as any).suggestionAfter).toBeDefined(); + expect((blockInfo as any).suggestionAfter.node.type.name).toBe( + "suggestion-paragraph", + ); + expect((blockInfo as any).suggestionAfter.node.textContent).toBe("Added"); + + // Should NOT have suggestionBefore + expect((blockInfo as any).suggestionBefore).toBeUndefined(); + + destroy(); + }); + + it("should include both suggestionBefore and suggestionAfter when both exist", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBoth(editor, "Before", "Main", "After"); + + const doc = editor.prosemirrorState.doc; + const blockContainerNode = doc.firstChild!.firstChild!; + + const blockInfo = getBlockInfoWithManualOffset(blockContainerNode, 0); + + expect(blockInfo.isBlockContainer).toBe(true); + if (!blockInfo.isBlockContainer) { + throw new Error("Expected blockInfo to be a blockContainer"); + } + expect((blockInfo as any).suggestionBefore).toBeDefined(); + expect((blockInfo as any).suggestionAfter).toBeDefined(); + expect((blockInfo as any).suggestionBefore.node.textContent).toBe("Before"); + expect((blockInfo as any).suggestionAfter.node.textContent).toBe("After"); + + destroy(); + }); + + it("should compute correct beforePos/afterPos for suggestion nodes", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Hi", "Main"); + + const doc = editor.prosemirrorState.doc; + const blockContainerNode = doc.firstChild!.firstChild!; + + // Use a known offset to make position arithmetic deterministic + const bnBlockBeforePos = 1; // position just before the blockContainer in the doc + const blockInfo = getBlockInfoWithManualOffset( + blockContainerNode, + bnBlockBeforePos, + ); + + expect(blockInfo.isBlockContainer).toBe(true); + if (!blockInfo.isBlockContainer) { + throw new Error("Expected blockInfo to be a blockContainer"); + } + const suggBefore = (blockInfo as any).suggestionBefore; + expect(suggBefore).toBeDefined(); + + // The suggestion node should start right after blockContainer opens + expect(suggBefore.beforePos).toBe(bnBlockBeforePos + 1); + expect(suggBefore.afterPos).toBe( + suggBefore.beforePos + suggBefore.node.nodeSize, + ); + + // blockContent should start right after the suggestion node + expect(blockInfo.blockContent.beforePos).toBe(suggBefore.afterPos); + + destroy(); + }); + + it("should still find blockGroup when suggestion nodes are also present", () => { + const { editor, destroy } = createMountedEditor(); + injectBlockWithChildren(editor, { + mainText: "Main", + childText: "Child", + suggestionBefore: "Before", + suggestionAfter: "After", + }); + + const doc = editor.prosemirrorState.doc; + const blockContainerNode = doc.firstChild!.firstChild!; + + const blockInfo = getBlockInfoWithManualOffset(blockContainerNode, 0); + + expect(blockInfo.isBlockContainer).toBe(true); + if (!blockInfo.isBlockContainer) { + throw new Error("Expected blockInfo to be a blockContainer"); + } + expect(blockInfo.childContainer).toBeDefined(); + expect(blockInfo.childContainer!.node.type.name).toBe("blockGroup"); + expect((blockInfo as any).suggestionBefore).toBeDefined(); + expect((blockInfo as any).suggestionAfter).toBeDefined(); + + destroy(); + }); +}); + +// ============================================================================= +// Tier 2C: selectionAtBlockStart / selectionAtBlockEnd with suggestions +// ============================================================================= + +describe("Tier 2C - Backspace selectionAtBlockStart with suggestion nodes", () => { + it("should revert non-paragraph block to paragraph when cursor is at start of leading suggestion", () => { + const { editor, destroy } = createMountedEditor(); + + // Create a heading block with a leading suggestion-heading + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const suggestionHeading = nodes["suggestion-heading"].create( + { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + level: 2, + __suggestionData: "true", + }, + [editor.pmSchema.text("Deleted heading")], + ); + + const mainHeading = nodes.heading.create( + { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + level: 2, + }, + [editor.pmSchema.text("Main heading")], + ); + + const blockContainer = nodes.blockContainer.create({ id: "block-1" }, [ + suggestionHeading, + mainHeading, + ]); + + const blockGroup = nodes.blockGroup.create(null, [blockContainer]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); + + // Verify the heading was set up correctly + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + expect(bc.childCount).toBe(2); + expect(bc.firstChild!.type.name).toBe("suggestion-heading"); + expect(bc.child(1).type.name).toBe("heading"); + + // Position cursor at start of the suggestion-heading (position 0 within the suggestion node) + const blockInfo = getBlockInfoWithManualOffset(bc, 1); + if (blockInfo.isBlockContainer && (blockInfo as any).suggestionBefore) { + const suggPos = (blockInfo as any).suggestionBefore.beforePos + 1; + editor._tiptapEditor.commands.setTextSelection(suggPos); + } + + // Simulate backspace + editor._tiptapEditor.commands.keyboardShortcut("Backspace"); + + // The block type should now be paragraph (reverted from heading) + const newDoc = editor.prosemirrorState.doc; + const newBc = newDoc.firstChild!.firstChild!; + // Find the blockContent (non-suggestion child) + let blockContentType = ""; + newBc.forEach((child) => { + if (child.type.spec.group === "blockContent") { + blockContentType = child.type.name; + } + }); + expect(blockContentType).toBe("paragraph"); + + destroy(); + }); + + it("should merge with previous block when Backspace at start of leading suggestion (paragraph block)", () => { + const { editor, destroy } = createMountedEditor(); + + // Two blocks: block-1 is normal paragraph, block-2 has suggestion-before + injectTwoBlocks(editor, { + block1: { mainText: "First" }, + block2: { mainText: "Second", suggestionBefore: "Deleted" }, + }); + + // Get block info for block-2 to find suggestion position + const doc = editor.prosemirrorState.doc; + const block2 = doc.firstChild!.child(1); + const block2Offset = doc.firstChild!.firstChild!.nodeSize + 1; // after block-1 + const block2Info = getBlockInfoWithManualOffset(block2, block2Offset); + + if (block2Info.isBlockContainer && (block2Info as any).suggestionBefore) { + const suggPos = (block2Info as any).suggestionBefore.beforePos + 1; + editor._tiptapEditor.commands.setTextSelection(suggPos); + } + + // Press Backspace — should merge with previous block + editor._tiptapEditor.commands.keyboardShortcut("Backspace"); + + // Should now have one block with combined content + const blocks = editor.document; + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text", text: "FirstSecond" }), + ]), + ); + + destroy(); + }); +}); + +describe("Tier 2C - Delete selectionAtBlockEnd with suggestion nodes", () => { + it("should merge with next block when Delete at end of trailing suggestion", () => { + const { editor, destroy } = createMountedEditor(); + + // Two blocks: block-1 has trailing suggestion, block-2 is normal + injectTwoBlocks(editor, { + block1: { mainText: "First", suggestionAfter: "Added" }, + block2: { mainText: "Second" }, + }); + + // Position cursor at end of trailing suggestion + const doc = editor.prosemirrorState.doc; + const block1 = doc.firstChild!.firstChild!; + const block1Info = getBlockInfoWithManualOffset(block1, 1); + + if (block1Info.isBlockContainer && (block1Info as any).suggestionAfter) { + const suggAfter = (block1Info as any).suggestionAfter; + const endPos = suggAfter.afterPos - 1; + editor._tiptapEditor.commands.setTextSelection(endPos); + } + + // Press Delete — should merge next block into this one + editor._tiptapEditor.commands.keyboardShortcut("Delete"); + + // Should now have one block + const blocks = editor.document; + expect(blocks).toHaveLength(1); + + destroy(); + }); +}); + +// ============================================================================= +// Tier 2D: blockEmpty checks in Enter handler +// ============================================================================= + +describe("Tier 2D - Enter blockEmpty with suggestion nodes", () => { + it("should NOT treat block as empty when blockContent is empty but leading suggestion has content", () => { + const { editor, destroy } = createMountedEditor(); + + // Block with non-empty suggestion, empty blockContent + injectSuggestionBefore(editor, "Deleted text", ""); + + // Position cursor in the empty blockContent + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + const blockInfo = getBlockInfoWithManualOffset(bc, 1); + if (blockInfo.isBlockContainer) { + const pos = blockInfo.blockContent.beforePos + 1; + editor._tiptapEditor.commands.setTextSelection(pos); + } + + // Count blocks before Enter + const blocksBefore = editor.document; + const blockCountBefore = blocksBefore.length; + + // Press Enter + editor._tiptapEditor.commands.keyboardShortcut("Enter"); + + // The block should NOT have been treated as "empty" and deleted/unnested. + // Instead, a new block should be created (split behavior). + const blocksAfter = editor.document; + // We should have more blocks (split creates a new one), not fewer + expect(blocksAfter.length).toBeGreaterThanOrEqual(blockCountBefore); + + destroy(); + }); + + it("should treat block as truly empty when both blockContent and all suggestion nodes are empty", () => { + const { editor, destroy } = createMountedEditor(); + + // Block with empty suggestion AND empty blockContent + injectSuggestionBefore(editor, "", ""); + + // Position cursor in the empty blockContent + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + const blockInfo = getBlockInfoWithManualOffset(bc, 1); + if (blockInfo.isBlockContainer) { + const pos = blockInfo.blockContent.beforePos + 1; + editor._tiptapEditor.commands.setTextSelection(pos); + } + + // Press Enter on an empty block — should be treated as empty + // (this is the normal behavior for empty blocks) + editor._tiptapEditor.commands.keyboardShortcut("Enter"); + + // Verify the doc is still valid + editor.prosemirrorState.doc.check(); + + destroy(); + }); +}); + +// ============================================================================= +// Tier 2E: Delete handler childCount assumption +// ============================================================================= + +describe("Tier 2E - Delete handler childCount assumption with suggestions", () => { + it("should correctly handle next block with suggestion + blockContent + blockGroup (childCount != 2)", () => { + const { editor, destroy } = createMountedEditor(); + + // Create a doc with: + // Block 1: paragraph("First") — cursor at end + // Block 2: suggestion-paragraph("Deleted") + paragraph("") + blockGroup([child]) + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const bc1 = nodes.blockContainer.create({ id: "block-1" }, [ + nodes.paragraph.create(PARA_ATTRS, [editor.pmSchema.text("First")]), + ]); + + const childParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Child"), + ]); + const childContainer = nodes.blockContainer.create({ id: "child-1" }, [ + childParagraph, + ]); + const blockGroup = nodes.blockGroup.create(null, [childContainer]); + + const bc2 = nodes.blockContainer.create({ id: "block-2" }, [ + nodes["suggestion-paragraph"].create(SUGGESTION_PARA_ATTRS, [ + editor.pmSchema.text("Deleted"), + ]), + nodes.paragraph.create(PARA_ATTRS), // empty paragraph + blockGroup, + ]); + + const outerGroup = nodes.blockGroup.create(null, [bc1, bc2]); + const newDoc = nodes.doc.create(null, [outerGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); + + // Position cursor at end of block-1's content + const doc = editor.prosemirrorState.doc; + const block1 = doc.firstChild!.firstChild!; + const block1Info = getBlockInfoWithManualOffset(block1, 1); + if (block1Info.isBlockContainer) { + const endPos = block1Info.blockContent.afterPos - 1; + editor._tiptapEditor.commands.setTextSelection(endPos); + } + + // Press Delete — should handle the next block correctly despite childCount > 2 + // At minimum, this should NOT crash + expect(() => { + editor._tiptapEditor.commands.keyboardShortcut("Delete"); + }).not.toThrow(); + + // Verify doc is still valid + editor.prosemirrorState.doc.check(); + + destroy(); + }); +}); + +// ============================================================================= +// Tier 3A: splitBlock with suggestion nodes +// ============================================================================= + +describe("Tier 3A - splitBlock with suggestion nodes", () => { + it("should split block correctly when cursor is in blockContent with leading suggestion", () => { + const { editor, destroy } = createMountedEditor(); + + injectSuggestionBefore(editor, "Deleted", "HelloWorld"); + + // Position cursor between "Hello" and "World" (offset 5 in the paragraph) + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + const blockInfo = getBlockInfoWithManualOffset(bc, 1); + if (blockInfo.isBlockContainer) { + const pos = blockInfo.blockContent.beforePos + 1 + 5; // "Hello" = 5 chars + editor._tiptapEditor.commands.setTextSelection(pos); + } + + // Press Enter to split + editor._tiptapEditor.commands.keyboardShortcut("Enter"); + + // Verify doc is valid + editor.prosemirrorState.doc.check(); + + // Should have 2 blocks now + const blocks = editor.document; + expect(blocks).toHaveLength(2); + expect(blocks[0].content).toEqual([ + expect.objectContaining({ text: "Hello" }), + ]); + expect(blocks[1].content).toEqual([ + expect.objectContaining({ text: "World" }), + ]); + + // The leading suggestion should stay with the first block + const newDoc = editor.prosemirrorState.doc; + const firstBc = newDoc.firstChild!.firstChild!; + let hasSuggestion = false; + firstBc.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + hasSuggestion = true; + } + }); + expect(hasSuggestion).toBe(true); + + destroy(); + }); + + it("should split block correctly when cursor is in blockContent with trailing suggestion", () => { + const { editor, destroy } = createMountedEditor(); + + injectSuggestionAfter(editor, "HelloWorld", "Added"); + + // Position cursor between "Hello" and "World" + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + const blockInfo = getBlockInfoWithManualOffset(bc, 1); + if (blockInfo.isBlockContainer) { + const pos = blockInfo.blockContent.beforePos + 1 + 5; + editor._tiptapEditor.commands.setTextSelection(pos); + } + + // Split + editor._tiptapEditor.commands.keyboardShortcut("Enter"); + + // Verify valid + editor.prosemirrorState.doc.check(); + + // Two blocks + const blocks = editor.document; + expect(blocks).toHaveLength(2); + + // The trailing suggestion should stay with the second block + const newDoc = editor.prosemirrorState.doc; + const secondBc = newDoc.firstChild!.child(1); + let hasSuggestion = false; + secondBc.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + hasSuggestion = true; + } + }); + expect(hasSuggestion).toBe(true); + + destroy(); + }); + + it("should not crash when cursor is inside a suggestion node and Enter is pressed", () => { + const { editor, destroy } = createMountedEditor(); + + injectSuggestionBefore(editor, "Hello suggestion", "Main content"); + + // Position cursor inside the suggestion node at offset 5 ("Hello|suggestion") + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + const blockInfo = getBlockInfoWithManualOffset(bc, 1); + if (blockInfo.isBlockContainer && (blockInfo as any).suggestionBefore) { + const suggPos = (blockInfo as any).suggestionBefore.beforePos + 1 + 5; + editor._tiptapEditor.commands.setTextSelection(suggPos); + } + + // Press Enter — should not crash + expect(() => { + editor._tiptapEditor.commands.keyboardShortcut("Enter"); + }).not.toThrow(); + + // Verify doc is still valid + editor.prosemirrorState.doc.check(); + + // Should have 2 blocks now (a split happened) + const blocks = editor.document; + expect(blocks).toHaveLength(2); + + // The split should happen at blockContent start (redirected from suggestion), + // so the first block keeps the suggestion + its content, and the second block + // gets all the main content. + // Block 1 should have suggestion preserved at PM level + const newDoc = editor.prosemirrorState.doc; + const firstBc = newDoc.firstChild!.firstChild!; + let firstHasSuggestion = false; + let firstBlockContentText = ""; + firstBc.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + firstHasSuggestion = true; + } + if (child.type.spec.group === "blockContent") { + firstBlockContentText = child.textContent; + } + }); + expect(firstHasSuggestion).toBe(true); + + // The suggestion should be preserved intact (not split) + const suggNode = firstBc.firstChild!; + expect(suggNode.type.name).toBe("suggestion-paragraph"); + expect(suggNode.textContent).toBe("Hello suggestion"); + + // First block's blockContent should be empty (split at start of blockContent) + expect(firstBlockContentText).toBe(""); + + // Second block should have the main content + expect(blocks[1].content).toEqual([ + expect.objectContaining({ text: "Main content" }), + ]); + + destroy(); + }); +}); + +// ============================================================================= +// Tier 3B: mergeBlocks with suggestion nodes +// ============================================================================= + +describe("Tier 3B - mergeBlocks with suggestion nodes", () => { + it("should preserve leading suggestion of second block during merge", () => { + const { editor, destroy } = createMountedEditor(); + + injectTwoBlocks(editor, { + block1: { mainText: "First" }, + block2: { mainText: "Second", suggestionBefore: "Deleted" }, + }); + + // Position cursor at start of block-2's leading suggestion (= effective block start) + const doc = editor.prosemirrorState.doc; + const block2 = doc.firstChild!.child(1); + const block2Offset = + 1 + doc.firstChild!.firstChild!.nodeSize; // after blockGroup open + block1 + const block2Info = getBlockInfoWithManualOffset(block2, block2Offset); + if (block2Info.isBlockContainer && (block2Info as any).suggestionBefore) { + const pos = (block2Info as any).suggestionBefore.beforePos + 1; + editor._tiptapEditor.commands.setTextSelection(pos); + } + + // Backspace to merge + editor._tiptapEditor.commands.keyboardShortcut("Backspace"); + + // Verify doc is valid + editor.prosemirrorState.doc.check(); + + // Should have 1 block with merged content + const blocks = editor.document; + expect(blocks).toHaveLength(1); + + // Check that the suggestion node survived in the PM doc + const newDoc = editor.prosemirrorState.doc; + const mergedBc = newDoc.firstChild!.firstChild!; + let hasSuggestion = false; + mergedBc.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + hasSuggestion = true; + } + }); + expect(hasSuggestion).toBe(true); + + destroy(); + }); + + it("should preserve trailing suggestion of first block during merge", () => { + const { editor, destroy } = createMountedEditor(); + + injectTwoBlocks(editor, { + block1: { mainText: "First", suggestionAfter: "Added" }, + block2: { mainText: "Second" }, + }); + + // Position cursor at start of block-2's blockContent (selectionAtBlockStart since no suggestion-before) + const doc = editor.prosemirrorState.doc; + const block2 = doc.firstChild!.child(1); + const block2Offset = 1 + doc.firstChild!.firstChild!.nodeSize; + const block2Info = getBlockInfoWithManualOffset(block2, block2Offset); + if (block2Info.isBlockContainer) { + const pos = block2Info.blockContent.beforePos + 1; + editor._tiptapEditor.commands.setTextSelection(pos); + } + + // Backspace to merge + editor._tiptapEditor.commands.keyboardShortcut("Backspace"); + + // Verify doc is valid + editor.prosemirrorState.doc.check(); + + // Should have merged into 1 block + const blocks = editor.document; + expect(blocks).toHaveLength(1); + + // Check that the trailing suggestion survived in the PM doc + const newDoc = editor.prosemirrorState.doc; + const mergedBc = newDoc.firstChild!.firstChild!; + let hasSuggestion = false; + mergedBc.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + hasSuggestion = true; + } + }); + expect(hasSuggestion).toBe(true); + + destroy(); + }); +}); + +// ============================================================================= +// Tier 4A: dragging with suggestion nodes +// ============================================================================= + +describe("Tier 4A - dragging with suggestion nodes", () => { + it("should correctly resolve block when selection is inside a suggestion node", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Deleted text", "Main content"); + + // Place selection inside the suggestion node + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + const blockInfo = getBlockInfoWithManualOffset(bc, 1); + + if (blockInfo.isBlockContainer && (blockInfo as any).suggestionBefore) { + const suggPos = (blockInfo as any).suggestionBefore.beforePos + 1; + editor._tiptapEditor.commands.setTextSelection(suggPos); + } + + // The selection's parent node should be resolvable to a block + const state = editor._tiptapEditor.state; + const $from = state.selection.$from; + const parentNode = $from.node(); + const parentGroup = parentNode.type.spec.group as string; + + // The parent should be either blockContent or suggestionBlockContent + expect( + parentGroup === "blockContent" || + parentGroup === "suggestionBlockContent", + ).toBe(true); + + destroy(); + }); +}); + +// ============================================================================= +// Tier 5A: getBlockFromPos stub block +// ============================================================================= + +describe("Tier 5A - getBlockFromPos stub block", () => { + it("should not return hardcoded 'abc' id for suggestion node positions", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Deleted", "Main"); + + // Verify the blockContainer has a real ID + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + expect(bc.attrs.id).toBe("block-1"); + + // The suggestion node is the first child + const suggNode = bc.firstChild!; + expect(suggNode.type.name).toBe("suggestion-paragraph"); + + // Note: We can't easily call getBlockFromPos directly without a NodeView context. + // But we can verify the mechanism: when resolving the suggestion node's position, + // we should be able to find the parent blockContainer's ID. + const suggBeforePos = 2; // position of suggestion node (after doc + blockGroup + blockContainer opens) + const resolvedNode = doc.resolve(suggBeforePos).node(); + expect(resolvedNode.type.name.startsWith("suggestion-")).toBe(true); + + // Walk up to find the blockContainer + const depth = doc.resolve(suggBeforePos).depth; + let parentId: string | undefined; + for (let d = depth; d >= 0; d--) { + const ancestor = doc.resolve(suggBeforePos).node(d); + if (ancestor.type.name === "blockContainer") { + parentId = ancestor.attrs.id; + break; + } + } + expect(parentId).toBe("block-1"); + + destroy(); + }); +}); + +// ============================================================================= +// Tier 5B: fixColumnList.isEmptyColumn +// ============================================================================= + +// Note: Column tests require the multi-column extension which may not be available +// in the core package. These tests document the expected behavior but may need +// to be moved to the xl-multi-column package. + +// ============================================================================= +// Tier 5C: Placeholder empty doc check +// ============================================================================= + +describe("Tier 5C - Placeholder with suggestion nodes", () => { + it("should not break when suggestion nodes increase doc content size beyond 6", () => { + const { editor, destroy } = createMountedEditor(); + + // Inject suggestion — doc size will be > 6 + injectSuggestionBefore(editor, "", ""); + + // The doc should still be valid + editor.prosemirrorState.doc.check(); + + // Doc content size should be > 6 (the hardcoded empty-doc check) + expect(editor.prosemirrorState.doc.content.size).toBeGreaterThan(6); + + destroy(); + }); +}); + +// ============================================================================= +// Integration: Manual test scenario coverage +// ============================================================================= + +describe("Integration - Multi-block scenarios with suggestion nodes", () => { + /** + * Helper: creates the 3-block document from App.tsx: + * Block 1: [suggestion-paragraph("Hello from suggestion!"), paragraph("Hello from main!")] + * Block 2: [paragraph("Second block main"), suggestion-paragraph("Trailing suggestion")] + * Block 3: [paragraph("Third block, no suggestions")] + */ + function injectThreeBlocks(editor: BlockNoteEditor) { + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const bc1 = nodes.blockContainer.create({ id: "block-1" }, [ + nodes["suggestion-paragraph"].create(SUGGESTION_PARA_ATTRS, [ + editor.pmSchema.text("Hello from suggestion!"), + ]), + nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Hello from main!"), + ]), + ]); + + const bc2 = nodes.blockContainer.create({ id: "block-2" }, [ + nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Second block main"), + ]), + nodes["suggestion-paragraph"].create(SUGGESTION_PARA_ATTRS, [ + editor.pmSchema.text("Trailing suggestion"), + ]), + ]); + + const bc3 = nodes.blockContainer.create({ id: "block-3" }, [ + nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Third block, no suggestions"), + ]), + ]); + + const blockGroup = nodes.blockGroup.create(null, [bc1, bc2, bc3]); + const newDoc = nodes.doc.create(null, [blockGroup]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + }); + } + + // --- Block API transparency --- + + it("editor.document should show only blockContent, not suggestion nodes", () => { + const { editor, destroy } = createMountedEditor(); + injectThreeBlocks(editor); + + const blocks = editor.document; + expect(blocks).toHaveLength(3); + expect(blocks[0].type).toBe("paragraph"); + expect(blocks[0].content).toEqual([ + expect.objectContaining({ text: "Hello from main!" }), + ]); + expect(blocks[1].type).toBe("paragraph"); + expect(blocks[1].content).toEqual([ + expect.objectContaining({ text: "Second block main" }), + ]); + expect(blocks[2].type).toBe("paragraph"); + expect(blocks[2].content).toEqual([ + expect.objectContaining({ text: "Third block, no suggestions" }), + ]); + + destroy(); + }); + + // --- Backspace: merge block 2 into block 1 --- + + it("Backspace at start of block-2 (with leading main content) should merge into block-1", () => { + const { editor, destroy } = createMountedEditor(); + injectThreeBlocks(editor); + + // Position cursor at start of block-2's blockContent + const doc = editor.prosemirrorState.doc; + const block2 = doc.firstChild!.child(1); + const block2Offset = 1 + doc.firstChild!.firstChild!.nodeSize; + const block2Info = getBlockInfoWithManualOffset(block2, block2Offset); + if (block2Info.isBlockContainer) { + editor._tiptapEditor.commands.setTextSelection( + block2Info.blockContent.beforePos + 1, + ); + } + + editor._tiptapEditor.commands.keyboardShortcut("Backspace"); + editor.prosemirrorState.doc.check(); + + // Should now have 2 blocks (block-1 merged with block-2, block-3 remains) + const blocks = editor.document; + expect(blocks).toHaveLength(2); + + // First block should have merged content + expect(blocks[0].content).toEqual([ + expect.objectContaining({ text: "Hello from main!Second block main" }), + ]); + + destroy(); + }); + + // --- Backspace: merge block 3 into block 2 --- + + it("Backspace at start of block-3 should merge into block-2, preserving trailing suggestion", () => { + const { editor, destroy } = createMountedEditor(); + injectThreeBlocks(editor); + + // Position cursor at start of block-3's blockContent + const doc = editor.prosemirrorState.doc; + const block3 = doc.firstChild!.child(2); + const block3Offset = + 1 + + doc.firstChild!.firstChild!.nodeSize + + doc.firstChild!.child(1).nodeSize; + const block3Info = getBlockInfoWithManualOffset(block3, block3Offset); + if (block3Info.isBlockContainer) { + editor._tiptapEditor.commands.setTextSelection( + block3Info.blockContent.beforePos + 1, + ); + } + + editor._tiptapEditor.commands.keyboardShortcut("Backspace"); + editor.prosemirrorState.doc.check(); + + // Should now have 2 blocks + const blocks = editor.document; + expect(blocks).toHaveLength(2); + + // Second block should have merged content + expect(blocks[1].content).toEqual([ + expect.objectContaining({ + text: "Second block mainThird block, no suggestions", + }), + ]); + + // Trailing suggestion from block-2 should be preserved at PM level + const newDoc = editor.prosemirrorState.doc; + const mergedBlock = newDoc.firstChild!.child(1); + let trailingSuggestionText: string | undefined; + mergedBlock.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + trailingSuggestionText = child.textContent; + } + }); + expect(trailingSuggestionText).toBe("Trailing suggestion"); + + destroy(); + }); + + // --- Enter: split inside main paragraph of block with leading suggestion --- + + it("Enter inside main paragraph should split block, suggestion stays with first block", () => { + const { editor, destroy } = createMountedEditor(); + injectThreeBlocks(editor); + + // Position cursor inside block-1's paragraph at offset 5 ("Hello| from main!") + const doc = editor.prosemirrorState.doc; + const block1 = doc.firstChild!.firstChild!; + const block1Info = getBlockInfoWithManualOffset(block1, 1); + if (block1Info.isBlockContainer) { + // Position after "Hello" + editor._tiptapEditor.commands.setTextSelection( + block1Info.blockContent.beforePos + 1 + 5, + ); + } + + editor._tiptapEditor.commands.keyboardShortcut("Enter"); + editor.prosemirrorState.doc.check(); + + // Should now have 4 blocks + const blocks = editor.document; + expect(blocks).toHaveLength(4); + + // First block should have "Hello" + expect(blocks[0].content).toEqual([ + expect.objectContaining({ text: "Hello" }), + ]); + // Second block should have " from main!" + expect(blocks[1].content).toEqual([ + expect.objectContaining({ text: " from main!" }), + ]); + + // Leading suggestion should stay with first block at PM level + const newDoc = editor.prosemirrorState.doc; + const firstBc = newDoc.firstChild!.firstChild!; + let suggestionText: string | undefined; + firstBc.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + suggestionText = child.textContent; + } + }); + expect(suggestionText).toBe("Hello from suggestion!"); + + destroy(); + }); + + // --- Enter: split inside suggestion text (Tier 3A behavior) --- + + it("Enter inside suggestion text should split at blockContent start, keeping suggestion intact", () => { + const { editor, destroy } = createMountedEditor(); + injectThreeBlocks(editor); + + // Position cursor inside block-1's suggestion at offset 5 ("Hello| from suggestion!") + const doc = editor.prosemirrorState.doc; + const block1 = doc.firstChild!.firstChild!; + const block1Info = getBlockInfoWithManualOffset(block1, 1); + if (block1Info.isBlockContainer && (block1Info as any).suggestionBefore) { + const suggPos = + (block1Info as any).suggestionBefore.beforePos + 1 + 5; + editor._tiptapEditor.commands.setTextSelection(suggPos); + } + + editor._tiptapEditor.commands.keyboardShortcut("Enter"); + editor.prosemirrorState.doc.check(); + + // Should now have 4 blocks (3 original + 1 from split) + const blocks = editor.document; + expect(blocks).toHaveLength(4); + + // The suggestion should be intact (not split) in the first block + const newDoc = editor.prosemirrorState.doc; + const firstBc = newDoc.firstChild!.firstChild!; + let suggestionText = ""; + let blockContentText = ""; + firstBc.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + suggestionText = child.textContent; + } + if (child.type.spec.group === "blockContent") { + blockContentText = child.textContent; + } + }); + // Suggestion should be fully intact + expect(suggestionText).toBe("Hello from suggestion!"); + // BlockContent should be empty (split at start) + expect(blockContentText).toBe(""); + + // Second block should have the original main content + expect(blocks[1].content).toEqual([ + expect.objectContaining({ text: "Hello from main!" }), + ]); + + destroy(); + }); + + // --- Delete at end of block-2's trailing suggestion --- + + it("Delete at end of trailing suggestion should merge with next block", () => { + const { editor, destroy } = createMountedEditor(); + injectThreeBlocks(editor); + + // Position cursor at end of block-2's trailing suggestion + const doc = editor.prosemirrorState.doc; + const block2 = doc.firstChild!.child(1); + const block2Offset = 1 + doc.firstChild!.firstChild!.nodeSize; + const block2Info = getBlockInfoWithManualOffset(block2, block2Offset); + if (block2Info.isBlockContainer && (block2Info as any).suggestionAfter) { + const suggAfter = (block2Info as any).suggestionAfter; + // Position at end of trailing suggestion content + editor._tiptapEditor.commands.setTextSelection(suggAfter.afterPos - 1); + } + + editor._tiptapEditor.commands.keyboardShortcut("Delete"); + editor.prosemirrorState.doc.check(); + + // Should now have 2 blocks (block-2 merged with block-3) + const blocks = editor.document; + expect(blocks).toHaveLength(2); + + // The merged block should have combined content + expect(blocks[1].content).toEqual([ + expect.objectContaining({ + text: "Second block mainThird block, no suggestions", + }), + ]); + + destroy(); + }); + + // --- Undo: edit in suggestion, then undo --- + + it("Undo should revert edits made inside suggestion nodes", () => { + const { editor, destroy } = createMountedEditor(); + + // Inject suggestion without adding to history, so undo only affects user edits + const view = editor._tiptapEditor.view; + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + const suggestionParagraph = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + [editor.pmSchema.text("Original suggestion")], + ); + const mainParagraph = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Main content"), + ]); + const bc = nodes.blockContainer.create({ id: "block-1" }, [ + suggestionParagraph, + mainParagraph, + ]); + const bg = nodes.blockGroup.create(null, [bc]); + const newDoc = nodes.doc.create(null, [bg]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + tr.setMeta("addToHistory", false); + }); + + // Position cursor inside suggestion and type a character + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + const blockInfo = getBlockInfoWithManualOffset(bc, 1); + if (blockInfo.isBlockContainer && (blockInfo as any).suggestionBefore) { + const suggPos = (blockInfo as any).suggestionBefore.beforePos + 1; + editor._tiptapEditor.commands.setTextSelection(suggPos); + } + + // Insert text + editor._tiptapEditor.commands.insertContent("X"); + + // Verify the text was inserted + let suggText = ""; + editor.prosemirrorState.doc.firstChild!.firstChild!.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + suggText = child.textContent; + } + }); + expect(suggText).toBe("XOriginal suggestion"); + + // Undo using PM history command + undo(view.state, view.dispatch); + + // Verify the text was reverted + suggText = ""; + editor.prosemirrorState.doc.firstChild!.firstChild!.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + suggText = child.textContent; + } + }); + expect(suggText).toBe("Original suggestion"); + + destroy(); + }); + + // --- Undo after merge --- + + it("Undo after merge should restore blocks with suggestion nodes", () => { + const { editor, destroy } = createMountedEditor(); + + // Inject without adding to history + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + const bc1 = nodes.blockContainer.create({ id: "block-1" }, [ + nodes.paragraph.create(PARA_ATTRS, [editor.pmSchema.text("First")]), + ]); + const bc2 = nodes.blockContainer.create({ id: "block-2" }, [ + nodes["suggestion-paragraph"].create(SUGGESTION_PARA_ATTRS, [ + editor.pmSchema.text("Deleted"), + ]), + nodes.paragraph.create(PARA_ATTRS, [editor.pmSchema.text("Second")]), + ]); + const bg = nodes.blockGroup.create(null, [bc1, bc2]); + const newDoc = nodes.doc.create(null, [bg]); + tr.replaceWith(0, tr.doc.content.size, newDoc.content); + tr.setMeta("addToHistory", false); + }); + + // Position at effective start of block-2 and Backspace + const doc = editor.prosemirrorState.doc; + const block2 = doc.firstChild!.child(1); + const block2Offset = 1 + doc.firstChild!.firstChild!.nodeSize; + const block2Info = getBlockInfoWithManualOffset(block2, block2Offset); + if (block2Info.isBlockContainer && (block2Info as any).suggestionBefore) { + const suggBefore = (block2Info as any).suggestionBefore; + editor._tiptapEditor.commands.setTextSelection( + suggBefore.beforePos + 1, + ); + } + + editor._tiptapEditor.commands.keyboardShortcut("Backspace"); + editor.prosemirrorState.doc.check(); + + // Verify merge happened + expect(editor.document).toHaveLength(1); + + // Undo using PM history command + const view = editor._tiptapEditor.view; + undo(view.state, view.dispatch); + editor.prosemirrorState.doc.check(); + + // Should be back to 2 blocks + expect(editor.document).toHaveLength(2); + + // The suggestion node should be restored on block-2 + const restoredDoc = editor.prosemirrorState.doc; + const restoredBlock2 = restoredDoc.firstChild!.child(1); + let restoredSuggestionText: string | undefined; + restoredBlock2.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + restoredSuggestionText = child.textContent; + } + }); + expect(restoredSuggestionText).toBe("Deleted"); + + destroy(); + }); + + // --- Formatting inside suggestion nodes --- + + it("Bold formatting should work inside suggestion node content", () => { + const { editor, destroy } = createMountedEditor(); + injectSuggestionBefore(editor, "Hello suggestion", "Main content"); + + // Select text inside suggestion node + const doc = editor.prosemirrorState.doc; + const bc = doc.firstChild!.firstChild!; + const blockInfo = getBlockInfoWithManualOffset(bc, 1); + if (blockInfo.isBlockContainer && (blockInfo as any).suggestionBefore) { + const suggBefore = (blockInfo as any).suggestionBefore; + // Select "Hello" (first 5 chars) + editor._tiptapEditor.commands.setTextSelection({ + from: suggBefore.beforePos + 1, + to: suggBefore.beforePos + 1 + 5, + }); + } + + // Toggle bold + editor._tiptapEditor.commands.toggleMark("bold"); + + // Verify the mark was applied + const newDoc = editor.prosemirrorState.doc; + const suggestion = newDoc.firstChild!.firstChild!.firstChild!; + expect(suggestion.type.name).toBe("suggestion-paragraph"); + // First child should be text with bold mark + const firstChild = suggestion.firstChild!; + expect(firstChild.text).toBe("Hello"); + expect(firstChild.marks.some((m) => m.type.name === "bold")).toBe(true); + + destroy(); + }); + + // --- Enter in block with trailing suggestion --- + + it("Enter inside main paragraph of block-2 (trailing suggestion) should preserve trailing suggestion", () => { + const { editor, destroy } = createMountedEditor(); + injectThreeBlocks(editor); + + // Position cursor inside block-2's main paragraph at offset 7 ("Second | block main") + const doc = editor.prosemirrorState.doc; + const block2 = doc.firstChild!.child(1); + const block2Offset = 1 + doc.firstChild!.firstChild!.nodeSize; + const block2Info = getBlockInfoWithManualOffset(block2, block2Offset); + if (block2Info.isBlockContainer) { + editor._tiptapEditor.commands.setTextSelection( + block2Info.blockContent.beforePos + 1 + 7, + ); + } + + editor._tiptapEditor.commands.keyboardShortcut("Enter"); + editor.prosemirrorState.doc.check(); + + // Should now have 4 blocks + const blocks = editor.document; + expect(blocks).toHaveLength(4); + + // Block at position 1 should have "Second " + expect(blocks[1].content).toEqual([ + expect.objectContaining({ text: "Second " }), + ]); + // Block at position 2 should have "block main" + expect(blocks[2].content).toEqual([ + expect.objectContaining({ text: "block main" }), + ]); + + // Trailing suggestion should stay with the second part (position 2 at PM level) + const newDoc = editor.prosemirrorState.doc; + const splitSecondHalf = newDoc.firstChild!.child(2); + let splitTrailingSuggestionText: string | undefined; + splitSecondHalf.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + splitTrailingSuggestionText = child.textContent; + } + }); + expect(splitTrailingSuggestionText).toBe("Trailing suggestion"); + + destroy(); + }); +}); + +// ============================================================================= +// moveBlocks: suggestion nodes should survive block moves +// ============================================================================= +describe("moveBlocks with suggestion nodes", () => { + it("should preserve leading suggestion when moving block up", () => { + const { editor, destroy } = createMountedEditor(); + + // Create 2 blocks: + // block-1: plain paragraph "First" + // block-2: [suggestion-paragraph("Deleted"), paragraph("Second")] + injectTwoBlocks(editor, { + block1: { mainText: "First" }, + block2: { mainText: "Second", suggestionBefore: "Deleted" }, + }); + + // Verify setup: block-2 has suggestion node + const block2 = editor.prosemirrorState.doc.firstChild!.child(1); + let hasSuggestion = false; + block2.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + hasSuggestion = true; + } + }); + expect(hasSuggestion).toBe(true); + + // Move block-2 up (should become first block) + moveBlocksUp(editor, "block-2"); + + // Verify block-2 is now first and still has its suggestion node + const doc = editor.prosemirrorState.doc; + const firstBlock = doc.firstChild!.child(0); + expect(firstBlock.attrs.id).toBe("block-2"); + + let preservedSuggestionText: string | undefined; + firstBlock.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + preservedSuggestionText = child.textContent; + } + }); + expect(preservedSuggestionText).toBe("Deleted"); + + // Block API should still show clean blocks + const blocks = editor.document; + expect(blocks[0].id).toBe("block-2"); + expect(blocks[0].content).toEqual([ + expect.objectContaining({ text: "Second" }), + ]); + + destroy(); + }); + + it("should preserve trailing suggestion when moving block down", () => { + const { editor, destroy } = createMountedEditor(); + + // Create 2 blocks: + // block-1: [paragraph("First"), suggestion-paragraph("Added")] + // block-2: plain paragraph "Second" + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const para1 = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("First"), + ]); + const suggestion = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + [editor.pmSchema.text("Added")], + ); + const bc1 = nodes.blockContainer.create({ id: "block-1" }, [ + para1, + suggestion, + ]); + + const para2 = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Second"), + ]); + const bc2 = nodes.blockContainer.create({ id: "block-2" }, [para2]); + + const blockGroup = nodes.blockGroup.create({}, [bc1, bc2]); + const doc = nodes.doc.create({}, [blockGroup]); + + tr.replaceWith(0, tr.doc.nodeSize - 2, doc.content); + }); + + // Verify setup + const block1 = editor.prosemirrorState.doc.firstChild!.child(0); + let hasTrailingSuggestion = false; + block1.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + hasTrailingSuggestion = true; + } + }); + expect(hasTrailingSuggestion).toBe(true); + + // Move block-1 down (should become second block) + moveBlocksDown(editor, "block-1"); + + // Verify block-1 is now second and still has its trailing suggestion + const doc = editor.prosemirrorState.doc; + const secondBlock = doc.firstChild!.child(1); + expect(secondBlock.attrs.id).toBe("block-1"); + + let preservedSuggestionText: string | undefined; + secondBlock.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + preservedSuggestionText = child.textContent; + } + }); + expect(preservedSuggestionText).toBe("Added"); + + destroy(); + }); + + it("should preserve suggestions on both sides when moving block", () => { + const { editor, destroy } = createMountedEditor(); + + // Create 2 blocks: + // block-1: [suggestion("Before"), paragraph("Main"), suggestion("After")] + // block-2: plain paragraph "Other" + editor.transact((tr) => { + const { nodes } = editor.pmSchema; + + const suggBefore = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + [editor.pmSchema.text("Before")], + ); + const para1 = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Main"), + ]); + const suggAfter = nodes["suggestion-paragraph"].create( + SUGGESTION_PARA_ATTRS, + [editor.pmSchema.text("After")], + ); + const bc1 = nodes.blockContainer.create({ id: "block-1" }, [ + suggBefore, + para1, + suggAfter, + ]); + + const para2 = nodes.paragraph.create(PARA_ATTRS, [ + editor.pmSchema.text("Other"), + ]); + const bc2 = nodes.blockContainer.create({ id: "block-2" }, [para2]); + + const blockGroup = nodes.blockGroup.create({}, [bc1, bc2]); + const doc = nodes.doc.create({}, [blockGroup]); + + tr.replaceWith(0, tr.doc.nodeSize - 2, doc.content); + }); + + // Move block-1 down + moveBlocksDown(editor, "block-1"); + + // Verify block-1 preserved both suggestion nodes + const doc = editor.prosemirrorState.doc; + const movedBlock = doc.firstChild!.child(1); + expect(movedBlock.attrs.id).toBe("block-1"); + + let suggBeforeText: string | undefined; + let suggAfterText: string | undefined; + let contentText: string | undefined; + let foundContent = false; + movedBlock.forEach((child) => { + if (child.type.name.startsWith("suggestion-")) { + if (!foundContent) { + suggBeforeText = child.textContent; + } else { + suggAfterText = child.textContent; + } + } + if (child.type.spec.group === "blockContent") { + foundContent = true; + contentText = child.textContent; + } + }); + expect(suggBeforeText).toBe("Before"); + expect(suggAfterText).toBe("After"); + expect(contentText).toBe("Main"); + + destroy(); + }); +}); diff --git a/packages/core/src/pm-nodes/idk.md b/packages/core/src/pm-nodes/idk.md new file mode 100644 index 0000000000..eb6773e4e6 --- /dev/null +++ b/packages/core/src/pm-nodes/idk.md @@ -0,0 +1,3 @@ +If a node cannot hold it's attributed children (e.g. blockContainer > blockContent with multiple blockContent children), +we can instead expand the blockContainer's content definition to allow for a special node which can hold those additional children. +As long as the special node has a required attribute, it won't be possible for ProseMirror to automatically create it, so we can be sure that it will only be created when we explicitly want it to be (e.g. when we want to insert a blockContent inside another blockContent). diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 6df3e68aa4..7582570d2f 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -234,11 +234,78 @@ export function addNodeAndExtensionsToSpec< ); } + // Create a "suggestion" shadow node for this block type. + // It has the same content/attributes/rendering as the original, but: + // - belongs to group "suggestionBlockContent" (not "blockContent") + // - has distinctive HTML with data-suggestion="true" for round-trip parsing + // - has NO custom nodeView (uses vanilla renderHTML only) + const suggestionNode = Node.create({ + name: `${blockConfig.type}--attributed`, + content: (blockConfig.content === "inline" + ? "inline*" + : blockConfig.content === "none" + ? "" + : blockConfig.content) as TContent extends "inline" ? "inline*" : "", + group: "suggestionBlockContent", + selectable: false, + isolating: blockImplementation.meta?.isolating ?? true, + code: blockImplementation.meta?.code ?? false, + defining: blockImplementation.meta?.defining ?? true, + priority, + addAttributes() { + const attrs = propsToAttributes(blockConfig.propSchema); + // The __suggestionData attribute serves two purposes: + // 1. isRequired: true prevents ProseMirror's DOMParser from auto-creating + // suggestion nodes to satisfy optional content expressions + // 2. Rendered as data-suggestion="true" on the wrapper div for HTML parsing + attrs["yjs-suggestion-node"] = { + isRequired: true, + parseHTML: (element: HTMLElement) => { + return element.getAttribute("data-suggestion"); + }, + renderHTML: (attributes: Record) => { + return { + "data-suggestion": attributes["yjs-suggestion-node"] || "true", + }; + }, + }; + return attrs; + }, + parseHTML() { + // Only parse HTML elements that have both data-suggestion and + // data-content-type matching this block type. This ensures suggestion + // nodes are only recreated from BlockNote's own HTML serialization, + // never from arbitrary external HTML. + return [ + { + tag: `[data-suggestion="true"][data-content-type="${blockConfig.type}"]`, + contentElement: ".bn-inline-content", + priority: 60, // Higher priority than normal blockContent parse rules + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + const div = document.createElement("div"); + return wrapInBlockStructure( + { + dom: div, + contentDOM: blockConfig.content === "inline" ? div : undefined, + }, + blockConfig.type, + {}, + blockConfig.propSchema, + blockImplementation.meta?.fileBlockAccept !== undefined, + HTMLAttributes, + ); + }, + }); + return { config: blockConfig, implementation: { ...blockImplementation, node, + suggestionNode, render(block, editor) { const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index eed8cf9fa3..6a76d1ca05 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -101,6 +101,16 @@ export function getBlockFromPos< } // Gets parent blockContainer node const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); + if (blockContainer.type.name.startsWith("suggestion-")) { + // The blockContent is inside a suggestion node, which is inside a blockContainer. + // Return a stub block since suggestion nodes are transparent to the Block API. + return { type: "paragraph", id: "abc", props: {} } as SpecificBlock< + BSchema, + BType, + I, + S + >; + } // Gets block identifier const blockIdentifier = blockContainer.attrs.id; diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index ba3da8b737..d3d817cee5 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -205,6 +205,7 @@ export type LooseBlockSpec< | undefined; node: Node; + suggestionNode?: Node; }; extensions?: (Extension | ExtensionFactoryInstance)[]; }; diff --git a/packages/core/src/y/extensions/YSync.ts b/packages/core/src/y/extensions/YSync.ts index f4c9f73574..0acb7c8d10 100644 --- a/packages/core/src/y/extensions/YSync.ts +++ b/packages/core/src/y/extensions/YSync.ts @@ -118,6 +118,16 @@ export const YSyncExtension = createExtension( syncPlugin({ suggestionDoc: options.suggestionDoc, mapAttributionToMark, + attributedNodes: ( + nodeName: string, + kinds: { delete: boolean; insert: boolean; format: boolean }, + ) => { + const result = Boolean( + editor.schema.blockSpecs[nodeName] && kinds.delete, + ); + + return result; + }, }), ], runsBefore: ["default"], diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 1ab1b43da8..da722395b3 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -291,7 +291,7 @@ export function createReactBlockSpec< return ( { + const markType = schema.marks[k] @@ -2659,7 +2661,8 @@ index 0000000000000000000000000000000000000000..28426627d278e5a3f9ae01fb81bcbf9b + attributedVariant(canonicalNodeName(node.type.name), marksToFormattingAttributes(resultingMarks), attributedNodes, schema) + ] + if (targetType !== node.type) { -+ tr.setNodeMarkup(pos, targetType, node.attrs, resultingMarks) ++ console.log('Flipping node type from', node.type.name, 'to', targetType.name, 'with marks', resultingMarks) ++ tr.setNodeMarkup(pos, targetType, object.assign({ 'yjs-suggestion-node': true }, node.attrs), resultingMarks) + } else { + object.forEach(format ?? {}, (v, k) => { + if (v == null) { @@ -2680,6 +2683,7 @@ index 0000000000000000000000000000000000000000..28426627d278e5a3f9ae01fb81bcbf9b + * @return {import('prosemirror-state').Transaction} + */ +export const deltaToPSteps = (tr, d, pnode = tr.doc, currPos = { i: 0 }, attributedNodes = defaultAttributedNodes) => { ++ console.log('deltaToPSteps:', d) + const schema = tr.doc.type.schema + let currParentIndex = 0 + let nOffset = 0 @@ -2719,6 +2723,7 @@ index 0000000000000000000000000000000000000000..28426627d278e5a3f9ae01fb81bcbf9b + nOffset = 0 + } + } else { ++ console.log('on a retain') + // TODO see schema.js for more info on marking nodes + applyNodeFormat(tr, currPos.i, op.format, attributedNodes) + currParentIndex++ @@ -2727,6 +2732,7 @@ index 0000000000000000000000000000000000000000..28426627d278e5a3f9ae01fb81bcbf9b + } + } + } else if (delta.$modifyOp.check(op)) { ++ console.log('on a modify') + applyNodeFormat(tr, currPos.i, op.format, attributedNodes) + const child = pchildren[currParentIndex++] + const childStart = currPos.i @@ -2774,6 +2780,7 @@ index 0000000000000000000000000000000000000000..28426627d278e5a3f9ae01fb81bcbf9b + } + } + }) ++ console.log(tr, tr.steps.map(a => a.toJSON())) + return tr +} + @@ -2785,6 +2792,7 @@ index 0000000000000000000000000000000000000000..28426627d278e5a3f9ae01fb81bcbf9b + * @return {Node} + */ +export const deltaToPNode = (d, schema, dformat, attributedNodes = defaultAttributedNodes) => { ++ console.log('deltaToPNode', attributedNodes) + /** + * @type {Object} + */ @@ -2794,7 +2802,10 @@ index 0000000000000000000000000000000000000000..28426627d278e5a3f9ae01fb81bcbf9b + } + const dc = d.children.map(c => delta.$insertOp.check(c) ? c.insert.map(cn => deltaToPNode(cn, schema, c.format, attributedNodes)) : (delta.$textOp.check(c) ? [schema.text(c.insert, formattingAttributesToMarks(c.format, schema))] : [])) + const canonical = d.name == null ? 'doc' : canonicalNodeName(d.name) -+ const nodeType = schema.nodes[attributedVariant(canonical, dformat, attributedNodes, schema)] ++ const finalNodeName = attributedVariant(canonical, dformat, attributedNodes, schema) ++ console.log('final node name', finalNodeName) ++ const nodeType = schema.nodes[finalNodeName] ++ + if (!nodeType) { + throw new Error( + '[y/prosemirror]: node type does not exist in the schema: ' + d.name @@ -2802,8 +2813,14 @@ index 0000000000000000000000000000000000000000..28426627d278e5a3f9ae01fb81bcbf9b + } + const inputChildren = dc.flat(1) + const inputMarks = formattingAttributesToMarks(dformat, schema) ++ const finalAttrs = canonical !== nodeType.name ++ ? object.assign({ ++ 'yjs-suggestion-node': true ++ }, attrs) ++ : attrs ++ console.log('Creating node of type', nodeType.name, 'with attrs', finalAttrs, 'and marks', inputMarks) + const pNode = nodeType.createAndFill( -+ attrs, ++ finalAttrs, + inputChildren, + inputMarks + ) diff --git a/patches/@y__y@14.0.0-rc.16.patch b/patches/@y__y@14.0.0-rc.16.patch new file mode 100644 index 0000000000..42e00d2080 --- /dev/null +++ b/patches/@y__y@14.0.0-rc.16.patch @@ -0,0 +1,193 @@ +diff --git a/dist/src/utils/UndoManager.d.ts b/dist/src/utils/UndoManager.d.ts +index 2670b9688224b31267f9e16a21be73ae6b39af84..2f614bb70c302ee0b277f083ee6f1e15a0c73476 100644 +--- a/dist/src/utils/UndoManager.d.ts ++++ b/dist/src/utils/UndoManager.d.ts +@@ -20,7 +20,7 @@ export class StackItem { + * filter returns false, the type/item won't be deleted even it is in the + * undo/redo scope. + * @property {Set} [UndoManagerOptions.trackedOrigins=new Set([null])] +- * @property {boolean} [ignoreRemoteMapChanges] Experimental. By default, the UndoManager will never overwrite remote changes. Enable this property to enable overwriting remote changes on key-value changes (Y.Map, properties on Y.Xml, etc..). ++ * @property {boolean} [ignoreRemoteAttributeChanges] By default, the UndoManager will never overwrite remote changes. In some cases this might be the expected behavior. This property enables overwriting remote changes on attribute changes. (previously named `ignoreRemoteMapChanges`) + * @property {Doc} [doc] The document that this UndoManager operates on. Only needed if typeScope is empty. + */ + /** +@@ -52,7 +52,7 @@ export class UndoManager extends ObservableV2<{ + * @param {Doc|YType|Array} typeScope Limits the scope of the UndoManager. If this is set to a ydoc instance, all changes on that ydoc will be undone. If set to a specific type, only changes on that type or its children will be undone. Also accepts an array of types. + * @param {UndoManagerOptions} options + */ +- constructor(typeScope: Doc | YType | Array, { captureTimeout, captureTransaction, deleteFilter, trackedOrigins, ignoreRemoteMapChanges, doc }?: UndoManagerOptions); ++ constructor(typeScope: Doc | YType | Array, { captureTimeout, captureTransaction, deleteFilter, trackedOrigins, ignoreRemoteAttributeChanges, doc }?: UndoManagerOptions); + /** + * @type {Array} + */ +@@ -83,7 +83,7 @@ export class UndoManager extends ObservableV2<{ + */ + currStackItem: StackItem | null; + lastChange: number; +- ignoreRemoteMapChanges: boolean; ++ ignoreRemoteAttributeChanges: boolean; + captureTimeout: number; + /** + * @param {Transaction} transaction +@@ -151,7 +151,7 @@ export class UndoManager extends ObservableV2<{ + canRedo(): boolean; + } + export function undoContentIds(ydoc: Doc, contentIds: ContentIds, opts?: UndoManagerOptions): void; +-export function redoItem(transaction: Transaction, item: Item, redoitems: Set, itemsToDelete: IdSet, ignoreRemoteMapChanges: boolean, um: import("../utils/UndoManager.js").UndoManager): Item | null; ++export function redoItem(transaction: Transaction, item: Item, redoitems: Set, itemsToDelete: IdSet, ignoreRemoteAttributeChanges: boolean, um: import("../utils/UndoManager.js").UndoManager): Item | null; + export function keepItem(item: Item | null, keep: boolean): void; + export type UndoManagerOptions = { + captureTimeout?: number | undefined; +@@ -168,9 +168,9 @@ export type UndoManagerOptions = { + deleteFilter?: ((arg0: Item) => boolean) | undefined; + trackedOrigins?: Set | undefined; + /** +- * Experimental. By default, the UndoManager will never overwrite remote changes. Enable this property to enable overwriting remote changes on key-value changes (Y.Map, properties on Y.Xml, etc..). ++ * By default, the UndoManager will never overwrite remote changes. In some cases this might be the expected behavior. This property enables overwriting remote changes on attribute changes. (previously named `ignoreRemoteMapChanges`) + */ +- ignoreRemoteMapChanges?: boolean | undefined; ++ ignoreRemoteAttributeChanges?: boolean | undefined; + /** + * The document that this UndoManager operates on. Only needed if typeScope is empty. + */ +diff --git a/dist/src/utils/UndoManager.d.ts.map b/dist/src/utils/UndoManager.d.ts.map +index 597c791905316e578275c84f1a9265ffa78e092a..7937771c7c931d9ffd2b2761cc2b33b51cb4bc8c 100644 +--- a/dist/src/utils/UndoManager.d.ts.map ++++ b/dist/src/utils/UndoManager.d.ts.map +@@ -1 +1 @@ +-{"version":3,"file":"UndoManager.d.ts","sourceRoot":"","sources":["../../../src/utils/UndoManager.js"],"names":[],"mappings":"AAeA;IACE;;;OAGG;IACH,wBAHW,KAAK,aACL,KAAK,EASf;IANC,kCAAyB;IACzB,kCAAwB;IACxB;;OAEG;IACH,oBAAqB;CAExB;AAgGD;;;;;;;;;;;GAWG;AAEH;;;;;;GAMG;AAEH;;;;;;;;GAQG;AACH;wBAF8C,CAAS,IAAc,EAAd,cAAc,EAAE,IAAW,EAAX,WAAW,KAAE,IAAI;yBAAuB,CAAS,IAAc,EAAd,cAAc,EAAE,IAAW,EAAX,WAAW,KAAE,IAAI;qBAAmB,CAAS,IAAwD,EAAxD;QAAE,gBAAgB,EAAE,OAAO,CAAC;QAAC,gBAAgB,EAAE,OAAO,CAAA;KAAE,KAAE,IAAI;0BAAwB,CAAS,IAAc,EAAd,cAAc,EAAE,IAAW,EAAX,WAAW,KAAE,IAAI;;IAGnT;;;OAGG;IACH,uBAHW,GAAG,GAAC,KAAK,GAAC,KAAK,CAAC,KAAK,CAAC,sGACtB,kBAAkB,EAsG5B;IA3FC;;OAEG;IACH,OAFU,KAAK,CAAC,KAAK,GAAG,GAAG,CAAC,CAEb;IACf,SAAc;IAEd,qBA9CmB,IAAI,KAAE,OAAO,CA8CA;IAEhC,yBAAoC;IACpC,2BAlDmB,WAAW,KAAE,OAAO,CAkDK;IAC5C;;OAEG;IACH,WAFU,KAAK,CAAC,SAAS,CAAC,CAEP;IACnB;;OAEG;IACH,WAFU,KAAK,CAAC,SAAS,CAAC,CAEP;IACnB;;;;OAIG;IACH,SAFU,OAAO,CAEG;IACpB,iBAAoB;IACpB;;;;OAIG;IACH,eAFU,SAAS,GAAC,IAAI,CAEC;IACzB,mBAAmB;IACnB,gCAAoD;IACpD,uBAAoC;IACpC;;OAEG;IACH,uCAFW,WAAW,UAmDrB;IAOH;;;;OAIG;IACH,mBAFW,KAAK,CAAC,KAAK,GAAG,GAAG,CAAC,GAAG,KAAK,GAAG,GAAG,QAY1C;IAED;;OAEG;IACH,yBAFW,GAAG,QAIb;IAED;;OAEG;IACH,4BAFW,GAAG,QAIb;IAED,gEAcC;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,sBAEC;IAED;;;;OAIG;IACH,QAFY,SAAS,OAAC,CAWrB;IAED;;;;OAIG;IACH,QAFY,SAAS,OAAC,CAWrB;IAED;;;;OAIG;IACH,WAFY,OAAO,CAIlB;IAED;;;;OAIG;IACH,WAFY,OAAO,CAIlB;CAOF;AAWM,qCAJI,GAAG,cACH,UAAU,SACV,kBAAkB,QAM5B;AAsBM,sCAXI,WAAW,QACX,IAAI,aACJ,GAAG,CAAC,IAAI,CAAC,iBACT,KAAK,0BACL,OAAO,MACP,OAAO,yBAAyB,EAAE,WAAW,GAE5C,IAAI,GAAC,IAAI,CAyGpB;AAWM,+BAHI,IAAI,GAAC,IAAI,QACT,OAAO,QAOjB;;;;;;iCA9ZsB,WAAW,KAAE,OAAO;;;;;;;2BACpB,IAAI,KAAE,OAAO;;;;;;;;;;;;eAWtB,SAAS;YACT,GAAG;UACH,MAAM,GAAC,MAAM;wBACb,GAAG,CAAC,KAAK,EAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;;6BA3Id,iBAAiB;sBAUxB,aAAa;oBADf,UAAU;qBANK,oBAAoB"} +\ No newline at end of file ++{"version":3,"file":"UndoManager.d.ts","sourceRoot":"","sources":["../../../src/utils/UndoManager.js"],"names":[],"mappings":"AAeA;IACE;;;OAGG;IACH,wBAHW,KAAK,aACL,KAAK,EASf;IANC,kCAAyB;IACzB,kCAAwB;IACxB;;OAEG;IACH,oBAAqB;CAExB;AAgGD;;;;;;;;;;;GAWG;AAEH;;;;;;GAMG;AAEH;;;;;;;;GAQG;AACH;wBAF8C,CAAS,IAAc,EAAd,cAAc,EAAE,IAAW,EAAX,WAAW,KAAE,IAAI;yBAAuB,CAAS,IAAc,EAAd,cAAc,EAAE,IAAW,EAAX,WAAW,KAAE,IAAI;qBAAmB,CAAS,IAAwD,EAAxD;QAAE,gBAAgB,EAAE,OAAO,CAAC;QAAC,gBAAgB,EAAE,OAAO,CAAA;KAAE,KAAE,IAAI;0BAAwB,CAAS,IAAc,EAAd,cAAc,EAAE,IAAW,EAAX,WAAW,KAAE,IAAI;;IAGnT;;;OAGG;IACH,uBAHW,GAAG,GAAC,KAAK,GAAC,KAAK,CAAC,KAAK,CAAC,4GACtB,kBAAkB,EAsG5B;IA3FC;;OAEG;IACH,OAFU,KAAK,CAAC,KAAK,GAAG,GAAG,CAAC,CAEb;IACf,SAAc;IAEd,qBA9CmB,IAAI,KAAE,OAAO,CA8CA;IAEhC,yBAAoC;IACpC,2BAlDmB,WAAW,KAAE,OAAO,CAkDK;IAC5C;;OAEG;IACH,WAFU,KAAK,CAAC,SAAS,CAAC,CAEP;IACnB;;OAEG;IACH,WAFU,KAAK,CAAC,SAAS,CAAC,CAEP;IACnB;;;;OAIG;IACH,SAFU,OAAO,CAEG;IACpB,iBAAoB;IACpB;;;;OAIG;IACH,eAFU,SAAS,GAAC,IAAI,CAEC;IACzB,mBAAmB;IACnB,sCAAgE;IAChE,uBAAoC;IACpC;;OAEG;IACH,uCAFW,WAAW,UAmDrB;IAOH;;;;OAIG;IACH,mBAFW,KAAK,CAAC,KAAK,GAAG,GAAG,CAAC,GAAG,KAAK,GAAG,GAAG,QAY1C;IAED;;OAEG;IACH,yBAFW,GAAG,QAIb;IAED;;OAEG;IACH,4BAFW,GAAG,QAIb;IAED,gEAcC;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,sBAEC;IAED;;;;OAIG;IACH,QAFY,SAAS,OAAC,CAWrB;IAED;;;;OAIG;IACH,QAFY,SAAS,OAAC,CAWrB;IAED;;;;OAIG;IACH,WAFY,OAAO,CAIlB;IAED;;;;OAIG;IACH,WAFY,OAAO,CAIlB;CAOF;AAWM,qCAJI,GAAG,cACH,UAAU,SACV,kBAAkB,QAM5B;AAsBM,sCAXI,WAAW,QACX,IAAI,aACJ,GAAG,CAAC,IAAI,CAAC,iBACT,KAAK,gCACL,OAAO,MACP,OAAO,yBAAyB,EAAE,WAAW,GAE5C,IAAI,GAAC,IAAI,CA4GpB;AAWM,+BAHI,IAAI,GAAC,IAAI,QACT,OAAO,QAOjB;;;;;;iCAjasB,WAAW,KAAE,OAAO;;;;;;;2BACpB,IAAI,KAAE,OAAO;;;;;;;;;;;;eAWtB,SAAS;YACT,GAAG;UACH,MAAM,GAAC,MAAM;wBACb,GAAG,CAAC,KAAK,EAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;;6BA3Id,iBAAiB;sBAUxB,aAAa;oBADf,UAAU;qBANK,oBAAoB"} +\ No newline at end of file +diff --git a/dist/src/ytype.d.ts.map b/dist/src/ytype.d.ts.map +index 61397c8530690c01be91a97afa4007338ca8060e..608ab7ab770d86eba126fc306594eee2bce5cc72 100644 +--- a/dist/src/ytype.d.ts.map ++++ b/dist/src/ytype.d.ts.map +@@ -1 +1 @@ +-{"version":3,"file":"ytype.d.ts","sourceRoot":"","sources":["../../src/ytype.js"],"names":[],"mappings":"AA4CO,4CAAiH;AAUjH,6DAJI,KAAK,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,OAAC,WAC7B,OAAO,GACN,WAAW,OAAC,CAgCvB;AAWD;IACE;;;;;;OAMG;IACH,kBANW,IAAI,GAAC,IAAI,SACT,IAAI,GAAC,IAAI,SACT,MAAM,qBACN,GAAG,CAAC,MAAM,EAAC,GAAG,CAAC,MACf,0BAA0B,EAQpC;IALC,kBAAgB;IAChB,mBAAkB;IAClB,cAAkB;IAClB,oCAA0C;IAC1C,gFAAY;IAGd;;OAEG;IACH,gBAgBC;IAED;;;;;;;OAOG;IACH,wBAPW,WAAW,UACX,KAAK,UACL,MAAM;;aA4EhB;CACF;AAqHM,2CATI,WAAW,UACX,KAAK,WACL,oBAAoB,WACpB,OAAO,mBAAmB,EAAE,eAAe;;SA0BrD;AASM,iDANI,WAAW,UACX,KAAK,WACL,oBAAoB,UACpB,KAAK,CAAC,GAAG,CAAC,GAAC,MAAM;;SA0B3B;AAWM,wCARI,WAAW,WACX,oBAAoB,UACpB,MAAM,GACL,oBAAoB,CAkD/B;AAED;IACE;;;OAGG;IACH,eAHW,IAAI,SACJ,MAAM,EAOhB;IAHC,QAAU;IACV,cAAkB;IAClB,kBAA8C;CAEjD;AAqDM,mCAHI,KAAK,SACL,MAAM,4BAgDhB;AAWM,kDAJI,KAAK,CAAC,iBAAiB,CAAC,SACxB,MAAM,OACN,MAAM,QAiChB;AAQM,mCAHI,KAAK,GACJ,KAAK,CAAC,IAAI,CAAC,CAWtB;AAUM,wCAJI,KAAK,eACL,WAAW,SACV,MAAM,CAAC,GAAG,CAAC,QActB;AAED;;;GAGG;AACH,mBAFgC,KAAK,SAAvB,KAAK,CAAC,SAAU;IA2D5B;;;;OAIG;IACH,YAJ+B,EAAE,SAAnB,KAAK,CAAC,SAAU,KACnB,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,GACd,KAAK,CAAC,EAAE,CAAC,CAMpB;IAjED;;OAEG;IACH,mBAFW,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,OAAC,EAqDxC;IAlDC;;OAEG;IACH,MAFU,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAEwB;IAC/D;;OAEG;IACH,OAFU,IAAI,GAAC,IAAI,CAEF;IACjB;;OAEG;IACH,MAFU,GAAG,CAAC,MAAM,EAAC,IAAI,CAAC,CAEL;IACrB;;OAEG;IACH,QAFU,IAAI,GAAC,IAAI,CAED;IAClB;;OAEG;IACH,KAFU,GAAG,GAAC,IAAI,CAEH;IACf,gBAAgB;IAChB;;;OAGG;IACH,KAFU,YAAY,CAAC,MAAM,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,EAAC,WAAW,CAAC,CAEhC;IAC/B;;;OAGG;IACH,MAFU,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,EAAC,WAAW,CAAC,CAEjB;IAChC;;OAEG;IACH,eAFU,IAAI,GAAG,KAAK,CAAC,iBAAiB,CAAC,CAEhB;IACzB;;;OAGG;IACH,iBAAqE;IACrE,uBAA8E;IAK9E;;;OAGG;IACH,wBAA2B;IAc7B,qBAGC;IAED;;;OAGG;IACH,cAFU,KAAK,CAAC,YAAY,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAIhD;IAED;;OAEG;IACH,cAFY,KAAK,CAAC,GAAG,CAAC,OAAC,CAItB;IAED;;;;;;;;;OASG;IACH,cAHW,GAAG,QACH,IAAI,GAAC,IAAI,QASnB;IAFG,aAAmB;IAIvB;;OAEG;IACH,SAFY,KAAK,CAAC,KAAK,CAAC,CAMvB;IAED;;;;;;OAMG;IACH,2BAHW,WAAW,cACX,GAAG,CAAC,IAAI,GAAC,MAAM,CAAC,QAY1B;IAED;;;;;;OAMG;IACH,QAJ8E,CAAC,SAAlE,CAAE,MAAM,EAAE,MAAM,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,EAAE,WAAW,KAAK,IAAK,KAClE,CAAC,GACA,CAAC,CAKZ;IAED;;;;;;OAMG;IACH,YAJwD,CAAC,SAA5C,CAAU,IAAa,EAAb,MAAM,CAAC,KAAK,CAAC,EAAC,IAAW,EAAX,WAAW,KAAE,IAAK,KAC5C,CAAC,GACA,CAAC,CAKZ;IAED;;;;OAIG;IACH,aAFW,CAAC,IAAI,EAAC,MAAM,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,EAAC,EAAE,EAAC,WAAW,KAAG,IAAI,QAIjE;IAED;;;;OAIG;IACH,iBAFW,CAAS,IAAa,EAAb,MAAM,CAAC,KAAK,CAAC,EAAC,IAAW,EAAX,WAAW,KAAE,IAAI,QAIlD;IAED;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,eAdwB,IAAI,SAAf,OAAS,eAEX,0BAA0B,SAElC;QAAsB,aAAa;QACZ,aAAa;QACb,aAAa;QACd,YAAY;QACc,QAAQ;QACpC,IAAI;KACxB,GAAS,IAAI,SAAS,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,qBAAqB,CAAC,KAAK,CAAC,CAAC,CAkO7F;IAED;;;;;;OAMG;IACH,iBAHW,0BAA0B,GACzB,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAI7B;IAED;;;;;;;OAOG;IACH,qBALW,KAAK,CAAC,QAAQ,OACd,0BAA0B,QA8CpC;IAED;;;;;;OAMG;IACH,SAFY,KAAK,CAAC,KAAK,CAAC,CAMvB;IAED;;OAEG;IACH,mBAMC;IAED;;;;;;OAMG;IACH,iCAJW,MAAM,QAMhB;IAED;;;;;;;;;;;OAWG;IACH,eAToE,GAAG,SAAzD,OAAO,CAAC,MAAM,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,EAAC,MAAM,CAAE,EAChB,GAAG,SAAxC,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,GAAG,CAAE,iBAEvC,GAAG,kBACH,GAAG,GACF,GAAG,CAOd;IAED;;;;;;;OAOG;IACH,eAL2E,GAAG,SAAhE,OAAO,CAAC,MAAM,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,EAAC,MAAM,GAAC,MAAM,CAAE,iBAC/D,GAAG,GACF,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,GAAC,SAAS,CAKxD;IAED;;;;;;;OAOG;IACH,8BALW,MAAM,GACL,OAAO,CAMlB;IAED;;;;;;;OAOG;IACH,2BALW,QAAQ,GACP,GAAG,GAAG,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,EAAC,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,GAAC,CAMjH;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,cAJW,MAAM,WACN,KAAK,CAAC,KAAK,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC,GAAC,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,WACtE,KAAK,CAAC,oBAAoB,QAIpC;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,cALW,MAAM,UACN,MAAM,WACN,KAAK,CAAC,oBAAoB,QAKpC;IAED;;;;;;OAMG;IACH,cAJW,KAAK,CAAC,KAAK,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC,GAAC,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,QAMhF;IAED;;;;OAIG;IACH,iBAFW,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,QAIvC;IAED;;;;;OAKG;IACH,cAHW,MAAM,WACN,MAAM,QAIhB;IAED;;;;;OAKG;IACH,WAHW,MAAM,GACL,KAAK,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAI5C;IAED;;;;;;;OAOG;IACH,cAJW,MAAM,QACN,MAAM,GACL,KAAK,CAAC,KAAK,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC,CAInD;IAED;;;;;;OAMG;IACH,WAFY,KAAK,CAAC,KAAK,CAAC,oBAAoB,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC,CAkBnF;IAED;;;OAGG;IACH,UAFY;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE;YAAE,CAAC,CAAC,EAAC,MAAM,GAAC,MAAM,GAAG,GAAG,CAAA;SAAE,CAAC;QAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;KAAG,CA0BxF;IAED;;;OAGG;IACH,wBAFG;QAAuB,QAAQ;KACjC,UAqBA;IAED;;;;;;;;OAQG;IACH,IALa,CAAC,KACH,CAAC,KAAK,EAAC,KAAK,CAAC,oBAAoB,CAAC,KAAK,CAAC,GAAC,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,EAAC,KAAK,EAAC,MAAM,KAAG,CAAC,GACtF,KAAK,CAAC,CAAC,CAAC,CAKnB;IAED;;;;OAIG;IACH,WAFW,CAAC,KAAK,EAAC,KAAK,CAAC,oBAAoB,CAAC,KAAK,CAAC,GAAC,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,EAAC,KAAK,EAAC,MAAM,KAAG,GAAG,QAInG;IAED;;;;OAIG;IACH,eAFW,CAAC,GAAG,EAAC,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAC,GAAG,EAAC,OAAO,CAAC,MAAM,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,EAAC,MAAM,CAAC,EAAC,KAAK,EAAC,IAAI,KAAG,GAAG,QAQ5H;IAED;;;;OAIG;IACH,YAFY,gBAAgB,CAAC,OAAO,SAAS,EAAE,KAAK,CAAC,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,CAIpF;IAED;;;;OAIG;IACH,cAFY,gBAAgB,CAAC,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAIhE;IAED;;;;OAIG;IACH,eAFY,gBAAgB,CAAC,GAAG,CAAC,IAAI,MAAM,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAC,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,GAAE,CAAC,GAAG,CAAC,CAAC,CAIxH;IAED;;;;OAIG;IACH,gBAFY,MAAM,CAIjB;IASD;;;;;;;;;OASG;IACH,gBAFW,eAAe,GAAG,eAAe,QAW3C;IA1BD;;OAEG;IACH,oCAFW,IAAI,WAId;CAsBF;AAOM,uBAJ+C,KAAK,SAA9C,OAAQ,YAAY,EAAE,iBAAkB,UAC1C,KAAK,GACJ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,YAAY,EAAE,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC,CAElB;AACpD,6CAA6C;AAMtC,gDAHI,WAAW,SACX,KAAK,uCAiBf;AAOM,8BAJI,GAAG,KACH,GAAG,GACF,OAAO,CAEgH;AAyB5H,oCARI,KAAK,CAAC,GAAG,CAAC,SACV,MAAM,OACN,MAAM,GACL,KAAK,CAAC,GAAG,CAAC,CAgCrB;AAYM,kCAPI,KAAK,SACL,MAAM,GACL,GAAG,CAqBd;AAaM,yDARI,WAAW,UACX,KAAK,iBACL,IAAI,OAAC,WACL,KAAK,CAAC,MAAM,CAAC,QA4DvB;AAaM,oDARI,WAAW,UACX,KAAK,SACL,MAAM,WACN,KAAK,CAAC;QAAO,MAAM,GAAC,GAAG;CAAC,GAAC,KAAK,CAAC,GAAG,CAAC,GAAC,MAAM,GAAC,IAAI,GAAC,MAAM,GAAC,UAAU,CAAC,QA4C5E;AAaM,kDAPI,WAAW,UACX,KAAK,WACL,KAAK,CAAC;QAAO,MAAM,GAAC,GAAG;CAAC,GAAC,KAAK,CAAC,GAAG,CAAC,GAAC,MAAM,GAAC,IAAI,GAAC,MAAM,GAAC,UAAU,CAAC,QAe5E;AAWM,4CARI,WAAW,UACX,KAAK,SACL,MAAM,UACN,MAAM,QAyChB;AAYM,2CAPI,WAAW,UACX,KAAK,OACL,MAAM,QAUhB;AAWM,wCARI,WAAW,UACX,KAAK,OACL,MAAM,SACN,MAAM,QAqChB;AAUM,mCAPI,KAAK,CAAC,GAAG,CAAC,OACV,MAAM,GACL;QAAO,MAAM,GAAC,GAAG;CAAC,GAAC,MAAM,GAAC,IAAI,GAAC,KAAK,CAAC,GAAG,CAAC,GAAC,MAAM,GAAC,UAAU,GAAC,KAAK,CAAC,GAAG,CAAC,GAAC,SAAS,CAS3F;AASM,sCANI,KAAK,CAAC,GAAG,CAAC;;;;EAkBpB;AA0BM,gCAf8B,SAAS,SAAhC,KAAK,CAAC,eAAgB,KACzB,SAAS,UACT,KAAK,iBACL,GAAG,CAAC,MAAM,GAAC,IAAI,CAAC,OAAC,MACjB,0BAA0B,QAC1B,OAAO,aACP,GAAG,CAAC,KAAK,CAAC,GAAC,GAAG,CAAC,KAAK,EAAC,GAAG,CAAC,GAAC,IAAI,iBAC9B,KAAK,OAAC,kBACN,KAAK,OAAC,SACN,GAAG,YACH,GAAG,QA8Cb;AAUM,mCAPI,KAAK,CAAC,GAAG,CAAC,OACV,MAAM,GACL,OAAO,CASlB;AASM,gCANI,IAAI,YACJ,QAAQ,GAAC,SAAS,WAO+F;AAWrH,2CARI,KAAK,CAAC,GAAG,CAAC,OACV,MAAM,YACN,QAAQ,GACP;QAAO,MAAM,GAAC,GAAG;CAAC,GAAC,MAAM,GAAC,IAAI,GAAC,KAAK,CAAC,GAAG,CAAC,GAAC,MAAM,GAAC,UAAU,GAAC,KAAK,CAAC,GAAG,CAAC,GAAC,SAAS,CAW3F;AAUM,8CAPI,KAAK,CAAC,GAAG,CAAC,YACV,QAAQ;;;;EAwBlB;AASM,wCANI,KAAK,CAAC,GAAG,CAAC,GAAG;IAAE,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;CAAE,GACvC,gBAAgB,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAQvC;AAQM,yCAHI,eAAe,GAAG,eAAe,GAChC,WAAW,CAEsD;AAQtE,2CAHI,eAAe,GAAG,eAAe,GAChC,aAAa,CAE0D;AAQ5E,yCAHI,eAAe,GAAG,eAAe,GAChC,WAAW,CActB;AAMM,2CAHI,eAAe,GAAG,eAAe,GAChC,aAAa,CAE2E;AAQ7F,0CAHI,eAAe,GAAG,eAAe,GAChC,YAAY,CAEuD;AAQxE,wCAHI,eAAe,GAAG,eAAe,GAChC,UAAU,CAE0E;AAMzF,wCAHI,eAAe,GAAG,eAAe,GAChC,UAAU,CASrB;AAMM,2CAHI,eAAe,GAAG,eAAe,GAChC,aAAa,CAEuD;AAQzE,4CAHI,eAAe,GAAG,eAAe,GAChC,cAAc,CAEwD;AAElF;;;;GAIG;AACH,0BAFU,KAAK,CAAC,CAAS,IAAiC,EAAjC,eAAe,GAAG,eAAe,KAAE,eAAe,CAAC,CAc3E;AAMM,yCAHI,eAAe,GAAG,eAAe,QACjC,MAAM,+CAE0E;AASpF,mCANI,eAAe,GAAG,eAAe,GAChC,KAAK,CAUhB;qBAvkEY;QAAO,MAAM,GAAC,GAAG;CAAC,GAAC,KAAK,CAAC,GAAG,CAAC,GAAC,MAAM,GAAC,IAAI,GAAC,MAAM,GAAC,UAAU,YAAQ,KAAK,CAAC,GAAG,CAAC;kCA48C3D,KAAK,SAAtB,KAAK,CAAC,SAAU,IACjB,KAAK,CAAC,kBAAkB,CAAC,KAAK,EAAE;IACtC,KAAK,EAAE,GAAG,CAAC,IAAI,MAAM,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,GAAG,YAAY,CAAC,KAAK,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,GAAE,CAAC;IACxG,QAAQ,EAAE,YAAY,CAAC,KAAK,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC,CAAA;CAC1D,CAAC;yBAKY,IAAI,oBACV,OAAO,CAAC,IAAI,EAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAC,KAAK,CAAC,QAAQ,CAAC,SAAS,KAAK,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,GAAG,CAAC,OAAO,SAAS,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,GAAG,KAAK,CAAC;qBAj+C7J,mBAAmB;uBAOH,mBAAmB;uBAhCnB,YAAY;wBASX,aAAa;mBADlB,aAAa;4BAiBzB,mBAAmB;8BAAnB,mBAAmB;4BAAnB,mBAAmB;8BAAnB,mBAAmB;6BAAnB,mBAAmB;2BAAnB,mBAAmB;2BAAnB,mBAAmB;8BAAnB,mBAAmB;+BAAnB,mBAAmB"} +\ No newline at end of file ++{"version":3,"file":"ytype.d.ts","sourceRoot":"","sources":["../../src/ytype.js"],"names":[],"mappings":"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"} +\ No newline at end of file +diff --git a/src/structs/Item.js b/src/structs/Item.js +index d3fb68a6086cab497099f265f95100258224db1b..5c4e621eb5e0341e002688b9e30927a7c2979185 100644 +--- a/src/structs/Item.js ++++ b/src/structs/Item.js +@@ -259,7 +259,7 @@ export class Item extends AbstractStruct { + // set as current parent value if right === null and this is parentSub + /** @type {YType} */ (this.parent)._map.set(this.parentSub, this) + if (this.left !== null) { +- // this is the current attribute value of parent. delete right ++ // this is the current attribute value of parent. delete the previous value + this.left.delete(transaction) + } + } +diff --git a/src/utils/UndoManager.js b/src/utils/UndoManager.js +index 5b94f87efb372d97fb8e943f607c585433dfbabb..27af08ca781bcdebc39206df93055e6a759a6c4f 100644 +--- a/src/utils/UndoManager.js ++++ b/src/utils/UndoManager.js +@@ -92,7 +92,7 @@ const popStackItem = (undoManager, stack, eventType) => { + } + }) + itemsToRedo.forEach(struct => { +- performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.inserts, undoManager.ignoreRemoteMapChanges, undoManager) !== null || performedChange ++ performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.inserts, undoManager.ignoreRemoteAttributeChanges, undoManager) !== null || performedChange + }) + // We want to delete in reverse order so that children are deleted before + // parents, so we have more information available when items are filtered. +@@ -131,7 +131,7 @@ const popStackItem = (undoManager, stack, eventType) => { + * filter returns false, the type/item won't be deleted even it is in the + * undo/redo scope. + * @property {Set} [UndoManagerOptions.trackedOrigins=new Set([null])] +- * @property {boolean} [ignoreRemoteMapChanges] Experimental. By default, the UndoManager will never overwrite remote changes. Enable this property to enable overwriting remote changes on key-value changes (Y.Map, properties on Y.Xml, etc..). ++ * @property {boolean} [ignoreRemoteAttributeChanges] By default, the UndoManager will never overwrite remote changes. In some cases this might be the expected behavior. This property enables overwriting remote changes on attribute changes. (previously named `ignoreRemoteMapChanges`) + * @property {Doc} [doc] The document that this UndoManager operates on. Only needed if typeScope is empty. + */ + +@@ -162,7 +162,7 @@ export class UndoManager extends ObservableV2 { + captureTransaction = _tr => true, + deleteFilter = () => true, + trackedOrigins = new Set([null]), +- ignoreRemoteMapChanges = false, ++ ignoreRemoteAttributeChanges = false, + doc = /** @type {Doc} */ (array.isArray(typeScope) ? typeScope[0].doc : typeScope instanceof Doc ? typeScope : typeScope.doc) + } = {}) { + super() +@@ -198,7 +198,7 @@ export class UndoManager extends ObservableV2 { + */ + this.currStackItem = null + this.lastChange = 0 +- this.ignoreRemoteMapChanges = ignoreRemoteMapChanges ++ this.ignoreRemoteAttributeChanges = ignoreRemoteAttributeChanges + this.captureTimeout = captureTimeout + /** + * @param {Transaction} transaction +@@ -415,14 +415,14 @@ const isDeletedByUndoStack = (stack, id) => array.some(stack, /** @param {StackI + * @param {Item} item + * @param {Set} redoitems + * @param {IdSet} itemsToDelete +- * @param {boolean} ignoreRemoteMapChanges ++ * @param {boolean} ignoreRemoteAttributeChanges + * @param {import('../utils/UndoManager.js').UndoManager} um + * + * @return {Item|null} + * + * @private + */ +-export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteMapChanges, um) => { ++export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteAttributeChanges, um) => { + const doc = transaction.doc + const store = doc.store + const ownClientID = doc.clientID +@@ -442,7 +442,7 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemo + // make sure that parent is redone + if (parentItem !== null && parentItem.deleted === true) { + // try to undo parent if it will be undone anyway +- if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteMapChanges, um) === null)) { ++ if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteAttributeChanges, um) === null)) { + return null + } + while (parentItem.redone !== null) { +@@ -491,7 +491,7 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemo + } + } else { + right = null +- if (item.right && !ignoreRemoteMapChanges) { ++ if (item.right && !ignoreRemoteAttributeChanges) { + left = item + // Iterate right while right is in itemsToDelete + // If it is intended to delete right while item is redone, we can expect that item should replace right. +@@ -508,6 +508,9 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemo + } else { + left = parentType._map.get(item.parentSub) || null + } ++ if (left !== null && /** @type {YType} */ (left.parent)._item !== parentItem) { ++ left = parentType._map.get(item.parentSub) || null ++ } + } + const nextClock = store.getClock(ownClientID) + const nextId = createID(ownClientID, nextClock) +diff --git a/src/ytype.js b/src/ytype.js +index ab79c2fe90d8b3c74b1c1ce6d7f62f714605f33c..bad51b3c1b80f8849eede060d412aa7d70bd6d3f 100644 +--- a/src/ytype.js ++++ b/src/ytype.js +@@ -1926,7 +1926,19 @@ export const typeMapGetDelta = (d, parent, attrsToRender, am, deep, modified, de + let c = array.last(content.getContent()) + if (deleted) { + if (itemsToRender == null || itemsToRender.hasId(item.lastId)) { +- d.deleteAttr(key, attribution, c) ++ if (attribution != null) { ++ // Item surfaced under attribution (suggestion view / diff AM, ++ // either in snapshot mode or in an event-driven render). The ++ // attribute is still observable in the rendered state, so emit ++ // a positive `SetAttrOp` carrying the attribution metadata - ++ // matching how content children are rendered for the same case ++ // (positive `InsertOp` with attribution, never `DeleteOp`). ++ d.setAttr(key, c, attribution) ++ } else { ++ // Hard-deleted attribute (no AM-surfaced attribution): emit the ++ // change op so event consumers can apply it. ++ d.deleteAttr(key, attribution, c) ++ } + } + } else if (deep && c instanceof YType && modified?.has(c)) { + d.modifyAttr(key, c.toDelta(am, opts)) diff --git a/patches/lib0@1.0.0-rc.13.patch b/patches/lib0@1.0.0-rc.13.patch new file mode 100644 index 0000000000..193dad31ed --- /dev/null +++ b/patches/lib0@1.0.0-rc.13.patch @@ -0,0 +1,466 @@ +diff --git a/dist/delta/delta.d.ts b/dist/delta/delta.d.ts +index 4b3d23babb76883d7a66c10ab9f170436484fcb2..1f97a3da5707f7602a72f8d15c6158c61321d949 100644 +--- a/dist/delta/delta.d.ts ++++ b/dist/delta/delta.d.ts +@@ -42,6 +42,22 @@ export const $attribution: s.Schema; + * @type {s.Schema} + */ + export const $deltaMapChangeJson: s.Schema; ++/** ++ * Invariants shared by all op classes below (TextOp, InsertOp, DeleteOp, ++ * RetainOp, ModifyOp, SetAttrOp, DeleteAttrOp, ModifyAttrOp): ++ * ++ * - **Only code inside `delta.js` may mutate op fields.** External consumers ++ * treat ops as immutable; structural fields are JSDoc-annotated `@readonly` ++ * to reinforce this. Mutation is permitted only while the owning Delta is ++ * not `done` — every builder entry point routes through `modDeltaCheck` ++ * to enforce this at runtime. ++ * - **Any mutation of a fingerprinted field MUST null `_fingerprint`.** The ++ * fingerprint is a lazy cache; if it has already been computed and the ++ * underlying data changes without invalidating it, every subsequent ++ * fingerprint read (and any `diff` / equality check that relies on it) is ++ * wrong. Fields covered: insert, delete, retain, format, attribution, ++ * value, key. ++ */ + export class TextOp extends list.ListNode { + /** + * @param {string} insert +@@ -125,9 +141,9 @@ export class InsertOp extends list.ListNode { + */ + _fingerprint: string | null; + /** +- * @param {ArrayContent} newVal ++ * @param {ArrayContent} _newVal + */ +- _updateInsert(newVal: ArrayContent): void; ++ _updateInsert(_newVal: ArrayContent): void; + /** + * @return {'insert'} + */ +@@ -184,10 +200,10 @@ export class DeleteOp extends list.ListNode { + /** + * Remove a part of the operation (similar to Array.splice) + * +- * @param {number} _offset ++ * @param {number} offset + * @param {number} len + */ +- _splice(_offset: number, len: number): this; ++ _splice(offset: number, len: number): this; + /** + * @return {DeltaListOpJSON} + */ +@@ -666,10 +682,12 @@ export class DeltaBuilder?} other +- * @param {{ final?: boolean }} opts -- experimental ++ * @param {{ final?: boolean }} opts -- (experimental) + * @return {DeltaBuilder} + */ + apply(other: Delta | null, { final }?: { +diff --git a/src/bin/0serve.js b/src/bin/0serve.js +index a69d09ba2effab926c2b0b24a147ad744064fe78..16cb9427c6ef666000b4463aa8734505c39e7563 100755 +--- a/src/bin/0serve.js ++++ b/src/bin/0serve.js +@@ -89,9 +89,17 @@ const server = http.createServer((req, res) => { + server.listen(port, host, () => { + logging.print(logging.BOLD, logging.ORANGE, `Server is running on http://${host}:${port}`) + if (paramOpenFile) { +- const start = debugBrowser || (process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open') ++ const url = `http://${host}:${port}/${paramOpenFile}` + import('child_process').then(cp => { +- cp.exec(`${start} http://${host}:${port}/${paramOpenFile}`) ++ if (debugBrowser) { ++ cp.execFile(debugBrowser, [url]) ++ } else if (process.platform === 'darwin') { ++ cp.execFile('open', [url]) ++ } else if (process.platform === 'win32') { ++ cp.execFile('cmd', ['/c', 'start', '', url]) ++ } else { ++ cp.execFile('xdg-open', [url]) ++ } + }) + } + }) +diff --git a/src/delta/delta.js b/src/delta/delta.js +index e063729e4515dd76aecd488655b26fec82a22ca9..d4be86c6af7aef2f0c51c32f2023c24152770c22 100644 +--- a/src/delta/delta.js ++++ b/src/delta/delta.js +@@ -101,6 +101,22 @@ const _cloneAttrs = attrs => attrs == null ? attrs : { ...attrs } + */ + const _markMaybeDeltaAsDone = maybeDelta => $deltaAny.check(maybeDelta) ? /** @type {MaybeDelta} */ (maybeDelta.done()) : maybeDelta + ++/** ++ * Invariants shared by all op classes below (TextOp, InsertOp, DeleteOp, ++ * RetainOp, ModifyOp, SetAttrOp, DeleteAttrOp, ModifyAttrOp): ++ * ++ * - **Only code inside `delta.js` may mutate op fields.** External consumers ++ * treat ops as immutable; structural fields are JSDoc-annotated `@readonly` ++ * to reinforce this. Mutation is permitted only while the owning Delta is ++ * not `done` — every builder entry point routes through `modDeltaCheck` ++ * to enforce this at runtime. ++ * - **Any mutation of a fingerprinted field MUST null `_fingerprint`.** The ++ * fingerprint is a lazy cache; if it has already been computed and the ++ * underlying data changes without invalidating it, every subsequent ++ * fingerprint read (and any `diff` / equality check that relies on it) is ++ * wrong. Fields covered: insert, delete, retain, format, attribution, ++ * value, key. ++ */ + export class TextOp extends list.ListNode { + /** + * @param {string} insert +@@ -228,14 +244,17 @@ export class InsertOp extends list.ListNode { + this._fingerprint = null + } + ++ /* c8 ignore start */ + /** +- * @param {ArrayContent} newVal ++ * @param {ArrayContent} _newVal + */ +- _updateInsert (newVal) { +- // @ts-ignore +- this.insert = newVal +- this._fingerprint = null ++ _updateInsert (_newVal) { ++ // Mirror of TextOp._updateInsert; not currently called on InsertOp because ++ // adjacent inserts are merged in-place via `end.insert.push(...)`. Kept for ++ // parity with TextOp's API. ++ error.unexpectedCase() // throw if called + } ++ /* c8 ignore stop */ + + /** + * @return {'insert'} +@@ -357,11 +376,13 @@ export class DeleteOp extends list.ListNode { + /** + * Remove a part of the operation (similar to Array.splice) + * +- * @param {number} _offset ++ * @param {number} offset + * @param {number} len + */ +- _splice (_offset, len) { +- this.prevValue = /** @type {any} */ (this.prevValue ? slice(this.prevValue, _offset, len) : null) ++ _splice (offset, len) { ++ if (this.prevValue) { ++ /** @type {DeltaBuilder} */ (this.prevValue).apply(create().retain(offset).delete(len)) ++ } + this._fingerprint = null + this.delete -= len + return this +@@ -547,6 +568,9 @@ export class ModifyOp extends list.ListNode { + }))) + } + ++ /* c8 ignore start */ ++ // ModifyOp has length 1, so callers never pass offset>0 or len>0 — splitHere ++ // is a no-op for length-1 ops. Kept for the structural _splice contract. + /** + * Remove a part of the operation (similar to Array.splice) + * +@@ -556,6 +580,7 @@ export class ModifyOp extends list.ListNode { + _splice (_offset, _len) { + return this + } ++ /* c8 ignore stop */ + + /** + * @return {DeltaListOpJSON} +@@ -851,7 +876,7 @@ export const $setAttrOpWith = $content => s.$custom(o => $setAttrOp.check(o) && + * @param {s.Schema} $content + * @return {s.Schema>} + */ +-export const $insertOpWith = $content => s.$custom(o => $insertOp.check(o) && $content.check(o.insert.every(ins => $content.check(ins)))) ++export const $insertOpWith = $content => s.$custom(o => $insertOp.check(o) && o.insert.every(ins => $content.check(ins))) + + /** + * @template {DeltaAny} Modify +@@ -1231,9 +1256,13 @@ const tryMergeWithPrev = (parent, op) => { + /** @type {DeleteOp} */ (prevOp).delete += op.delete + } else if ($textOp.check(op)) { + /** @type {TextOp} */ (prevOp)._updateInsert(/** @type {TextOp} */ (prevOp).insert + op.insert) ++ /* c8 ignore start */ + } else { ++ // unreachable: the constructor check at the top of the function already ++ // limits `op` to one of the four kinds tested above + error.unexpectedCase() + } ++ /* c8 ignore stop */ + list.remove(parent, op) + } + +@@ -1501,10 +1530,12 @@ export class DeltaBuilder extends Delta { + * + * a.apply(b).apply(c) + * +- * @todo fuzz test the above property ++ * If `final = true`, we consider this delta the final state and drop deleteAttrOps from ++ * attributes. (E.g. if `otherOp` deletes an attribute, this op will simply not have the ++ * attribute). Any kind of `delete` op might be considered a bug. A final delta is not idempotent. + * + * @param {Delta?} other +- * @param {{ final?: boolean }} opts -- experimental ++ * @param {{ final?: boolean }} opts -- (experimental) + * @return {DeltaBuilder} + */ + apply (other, { final = this.isFinal } = {}) { +@@ -1517,7 +1548,7 @@ export class DeltaBuilder extends Delta { + const c = /** @type {SetAttrOp|DeleteAttrOp|ModifyAttrOp} */ (this.attrs[op.key]) + if ($modifyAttrOp.check(op)) { + if ($deltaAny.check(c?.value)) { +- c._modValue.apply(op.value) ++ c._modValue.apply(op.value, { final }) + } else { + // then this is a simple modify + // @ts-ignore +@@ -1588,10 +1619,14 @@ export class DeltaBuilder extends Delta { + this.childCnt += op.length + } + for (const op of other.children) { ++ // defensive: the per-branch logic below resets opsI/offset whenever it ++ // consumes an op exactly. This guard catches any path that forgets to. ++ /* c8 ignore start */ + if (opsI?.length === offset) { + opsI = opNextUndeleted(opsI) + offset = 0 + } ++ /* c8 ignore stop */ + if ($textOp.check(op) || $insertOp.check(op)) { + insertClonedOp(op) + } else if ($retainOp.check(op)) { +@@ -1611,7 +1646,11 @@ export class DeltaBuilder extends Delta { + } + if (opsI != null) { + if (op.format != null && retainLen > 0) { +- offset = retainLen ++ // accumulate onto the existing offset — the else-branch below uses ++ // `offset += retainLen`, and we must agree with it when prior ++ // iterations have advanced offset into opsI without splitting (e.g. ++ // a format-less retain followed by a same-format retain). ++ offset += retainLen + splitHere() + updateOpFormat(/** @type {ChildrenOpAny} */ (opsI.prev), op.format) + scheduleForMerge(opsI.prev) +@@ -1670,9 +1709,12 @@ export class DeltaBuilder extends Delta { + opsI._splice(offset, delLen) + } + remainingLen -= delLen ++ /* c8 ignore start */ + } else { ++ // unreachable: opsI was already typed as retain | non-delete-content | delete above + error.unexpectedCase() + } ++ /* c8 ignore stop */ + } + } else if ($modifyOp.check(op)) { + if (opsI != null && op.format != null && (!$deleteOp.check(opsI) && !$retainOp.check(opsI))) { // retain handles splitting seperately, without copying attrs +@@ -1709,14 +1751,20 @@ export class DeltaBuilder extends Delta { + opsI._splice(0, 1) + scheduleForMerge(opsI) + } +- } else if ($deleteOp.check(opsI)) { +- // nop ++ /* c8 ignore start */ + } else { ++ // remaining branches: opsI is deleteOp or something unknown ++ // both branches are unreachable today: opNextUndeleted skips ++ // delete ops, so opsI is never a delete during iteration; and the four ++ // branches above exhaust the other op kinds. The deleteOp branch is ++ // kept as a defensive no-op (drops a modify that lands in a deleted ++ // region) rather than a throw. + error.unexpectedCase() + } + } else { + error.unexpectedCase() + } ++ /* c8 ignore stop */ + } + // iterate backwards, to ensure that we merge all content + for (let i = maybeMergeable.length - 1; i >= 0; i--) { +@@ -1772,9 +1820,12 @@ export class DeltaBuilder extends Delta { + // @ts-ignore + delete this.attrs[otherOp.key] + } ++ /* c8 ignore start */ + } else { ++ // unreachable: attr ops are exhaustively setAttr | deleteAttr | modifyAttr + error.unexpectedCase() + } ++ /* c8 ignore stop */ + } + /** + * Rebase children. +@@ -1831,7 +1882,10 @@ export class DeltaBuilder extends Delta { + otherOffset = otherChild.length + } else { + if ($modifyOp.check(otherChild)) { +- /** @type {any} */ (currChild.value).rebase(otherChild, priority) ++ // _modValue (not .value) — ModifyOp.clone() marks its inner delta ++ // as `done`, so a cloned ModifyOp can only be rebased after the ++ // _modValue getter lazy-clones it back to mutable. ++ currChild._modValue.rebase(otherChild.value, priority) + } else if ($deleteOp.check(otherChild)) { + list.remove(this.children, currChild) + this.childCnt -= 1 +@@ -1848,21 +1902,70 @@ export class DeltaBuilder extends Delta { + * - insert: split curr op and insert retain + */ + if ($retainOp.check(otherChild) || $modifyOp.check(otherChild)) { ++ // Format reconciliation. priority=true is a no-op (currChild's format ++ // wins). For !priority, currChild concedes any format key that ++ // otherChild also writes — but only over the [currOffset..currOffset+ ++ // maxCommonLen] overlap. Split currChild around the overlap so the ++ // prefix/suffix keep their original format and only the middle piece ++ // carries the stripped format. ++ if ( ++ !priority && ++ $retainOp.check(currChild) && ++ currChild.format != null && ++ otherChild.format != null ++ ) { ++ /** @type {FormattingAttributes} */ ++ const stripped = {} ++ let strippedAny = false ++ for (const k in currChild.format) { ++ if (k in otherChild.format) { ++ strippedAny = true ++ } else { ++ stripped[k] = currChild.format[k] ++ } ++ } ++ if (strippedAny) { ++ // split off the suffix [currOffset+maxCommonLen..length] if any ++ if (currOffset + maxCommonLen < currChild.length) { ++ const suffix = currChild.clone(currOffset + maxCommonLen, currChild.length) ++ list.insertBetween(this.children, currChild, currChild.next, suffix) ++ currChild._splice(currOffset + maxCommonLen, currChild.length - (currOffset + maxCommonLen)) ++ } ++ // split off the prefix [0..currOffset] if any ++ if (currOffset > 0) { ++ const prefix = currChild.clone(0, currOffset) ++ list.insertBetween(this.children, currChild.prev, currChild, prefix) ++ currChild._splice(0, currOffset) ++ currOffset = 0 ++ } ++ // currChild now spans exactly the overlap. Replace its format. ++ /** @type {any} */ (currChild).format = object.isEmpty(stripped) ? null : stripped ++ currChild._fingerprint = null ++ } ++ } + currOffset += maxCommonLen + otherOffset += maxCommonLen + } else if ($deleteOp.check(otherChild)) { + if ($retainOp.check(currChild)) { + // @ts-ignore + currChild.retain -= maxCommonLen ++ currChild._fingerprint = null + } else if ($deleteOp.check(currChild)) { + currChild.delete -= maxCommonLen ++ currChild._fingerprint = null + } + this.childCnt -= maxCommonLen +- } else { // insert/text.check(currOp) ++ // advance other so subsequent currChild ops see what comes AFTER this ++ // delete; without this we'd loop against the same delete forever and ++ // never reach other's later inserts. ++ otherOffset += maxCommonLen ++ } else { // insert/text.check(otherChild) + if (currOffset > 0) { +- const leftPart = currChild.clone(currOffset) ++ const leftPart = currChild.clone(0, currOffset) + list.insertBetween(this.children, currChild.prev, currChild, leftPart) +- currChild._splice(currOffset, currChild.length - currOffset) ++ // leftPart is the prefix; currChild becomes the suffix. Remove the ++ // prefix portion from currChild so it represents [currOffset..length]. ++ currChild._splice(0, currOffset) + currOffset = 0 + } + list.insertBetween(this.children, currChild.prev, currChild, new RetainOp(otherChild.length, null, null)) +@@ -2000,8 +2103,10 @@ export class $Delta extends s.Schema { + check (o, err = undefined) { + const { $name, $attrs, $children, hasText, $formats } = this.shape + if (!$deltaAny.check(o, err)) { ++ /* c8 ignore next */ + err?.extend(null, 'Delta', o?.constructor.name, 'Constructor match failed') + } else if (o.name != null && !$name.check(o.name, err)) { ++ /* c8 ignore next */ + err?.extend('Delta.name', $name.toString(), o.name, 'Unexpected node name') + } else if (list.toArray(o.children).some(c => (!hasText && $textOp.check(c)) || (hasText && $textOp.check(c) && c.format != null && !$formats.check(c.format)) || ($insertOp.check(c) && !c.insert.every(ins => $children.check(ins))))) { + err?.extend('Delta.children', '', '', 'Children don\'t match the schema') +@@ -2097,7 +2202,7 @@ export const mergeDeltas = (a, b) => { + c.apply(b) + return /** @type {any} */ (c) + } +- return a == null ? b : (a || null) ++ return /** @type {D} */ (a || b || null) + } + + /** +@@ -2325,6 +2430,16 @@ class _DiffStringWrapper { + */ + + /** ++ * Compute a delta that, when applied to `d1`, produces `d2`. Only the children and attributes of ++ * `d1` and `d2` are compared; the top-level node names of `d1` and `d2` are *not*. Diffing ++ * `
a
` against `a` is valid and yields an empty diff — they have the same ++ * children and attributes, so as far as `diff` is concerned they are equal at the level it cares ++ * about. The top-level name is treated as a document-type marker, not as diffable content. ++ * ++ * Names *are* compared on children: a child node whose name changes between `d1` and `d2` is ++ * replaced wholesale (delete + insert), not converted into a `modify` op. Same-name child nodes ++ * at aligned positions are paired and recursed into via `modify`. ++ * + * @template {DeltaConf} Conf + * @param {Delta} d1 + * @param {NoInfer>} d2 +@@ -2395,9 +2510,14 @@ export const diff = (d1, d2) => { + cs2.push(left2.insert) + } else if ($insertOp.check(left2)) { + cs2.push(...left2.insert.map(ins => typeof ins === 'string' ? new _DiffStringWrapper(ins) : ins)) ++ /* c8 ignore start */ + } else { ++ // unreachable for valid diff inputs (delete on the rhs would already ++ // have been rejected via the `[lib0/delta] diffing deletes unsupported` ++ // path above) + error.unexpectedCase() + } ++ /* c8 ignore stop */ + formattingNeedsDiff ||= left2.format != null + left2 = left2.next + } +@@ -2459,9 +2579,14 @@ export const diff = (d1, d2) => { + a = a.next + aOffset = 0 + } ++ /* c8 ignore start */ + } else { ++ // unreachable: by this point both a and b are insert/text (deletes ++ // were rejected upstream and `originalUpdated` is the result of an ++ // apply, which keeps inserts only). + error.unexpectedCase() + } ++ /* c8 ignore stop */ + } + // @todo instead of applying, we want to first exec d, then formattingDiff - we need a merge + // function! +@@ -2481,10 +2606,11 @@ export const diff = (d1, d2) => { + } else { + d.setAttr(key, nextVal) + } ++ /* c8 ignore start */ + } else { +- /* c8 ignore next 2 */ + error.unexpectedCase() + } ++ /* c8 ignore stop */ + } + } + for (const { key } of d1.attrs) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98b8f1b63f..3dcbdca0f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,9 @@ overrides: '@y/prosemirror>lib0': 1.0.0-rc.13 patchedDependencies: - '@y/prosemirror@2.0.0-2': 6d18e9340ac9dddf315d155e6f37a101c4a51c6ad4aef3dfb6485f6903a12cc8 + '@y/prosemirror@2.0.0-2': ec66603c60cb6190764d60fa75bd0c0fe532eda91a9c231037db169b8660ac5f + '@y/y@14.0.0-rc.16': 4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9 + lib0@1.0.0-rc.13: 328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e importers: @@ -234,16 +236,16 @@ importers: version: 0.6.4(react@19.2.5)(yjs@13.6.30) '@y/prosemirror': specifier: ^2.0.0-2 - version: 2.0.0-2(patch_hash=6d18e9340ac9dddf315d155e6f37a101c4a51c6ad4aef3dfb6485f6903a12cc8)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16))(@y/y@14.0.0-rc.16)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) + version: 2.0.0-2(patch_hash=ec66603c60cb6190764d60fa75bd0c0fe532eda91a9c231037db169b8660ac5f)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)))(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9))(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.16) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/websocket': specifier: ^4.0.0-3 - version: 4.0.0-rc.2(@y/y@14.0.0-rc.16) + version: 4.0.0-rc.2(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/y': specifier: ^14.0.0-rc.16 - version: 14.0.0-rc.16 + version: 14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9) ai: specifier: ^6.0.5 version: 6.0.5(zod@4.3.6) @@ -276,7 +278,7 @@ importers: version: '@fumadocs/base-ui@16.5.0(@types/react@19.2.14)(fumadocs-core@16.5.0(@types/react@19.2.14)(lucide-react@0.562.0(react@19.2.5))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.51.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.51.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.2)' lib0: specifier: 1.0.0-rc.13 - version: 1.0.0-rc.13 + version: 1.0.0-rc.13(patch_hash=328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e) lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.5) @@ -4041,16 +4043,16 @@ importers: version: 9.1.1(react@19.2.5) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.16) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/websocket': specifier: ^4.0.0-3 - version: 4.0.0-rc.2(@y/y@14.0.0-rc.16) + version: 4.0.0-rc.2(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/y': specifier: ^14.0.0-rc.16 - version: 14.0.0-rc.16 + version: 14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9) lib0: specifier: 1.0.0-rc.13 - version: 1.0.0-rc.13 + version: 1.0.0-rc.13(patch_hash=328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e) react: specifier: ^19.2.3 version: 19.2.5 @@ -4099,16 +4101,16 @@ importers: version: 9.1.1(react@19.2.5) '@y/prosemirror': specifier: ^2.0.0-2 - version: 2.0.0-2(patch_hash=6d18e9340ac9dddf315d155e6f37a101c4a51c6ad4aef3dfb6485f6903a12cc8)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16))(@y/y@14.0.0-rc.16)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) + version: 2.0.0-2(patch_hash=ec66603c60cb6190764d60fa75bd0c0fe532eda91a9c231037db169b8660ac5f)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)))(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9))(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.16) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/websocket': specifier: ^4.0.0-rc.2 - version: 4.0.0-rc.2(@y/y@14.0.0-rc.16) + version: 4.0.0-rc.2(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/y': specifier: ^14.0.0-rc.16 - version: 14.0.0-rc.16 + version: 14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9) react: specifier: ^19.2.3 version: 19.2.5 @@ -4206,16 +4208,16 @@ importers: version: 9.1.1(react@19.2.5) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.16) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/websocket': specifier: ^4.0.0-3 - version: 4.0.0-rc.2(@y/y@14.0.0-rc.16) + version: 4.0.0-rc.2(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/y': specifier: ^14.0.0-rc.16 - version: 14.0.0-rc.16 + version: 14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9) lib0: specifier: 1.0.0-rc.13 - version: 1.0.0-rc.13 + version: 1.0.0-rc.13(patch_hash=328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e) react: specifier: ^19.2.3 version: 19.2.5 @@ -4956,13 +4958,13 @@ importers: version: 3.22.4 '@y/prosemirror': specifier: ^2.0.0-2 - version: 2.0.0-2(patch_hash=6d18e9340ac9dddf315d155e6f37a101c4a51c6ad4aef3dfb6485f6903a12cc8)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16))(@y/y@14.0.0-rc.16)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) + version: 2.0.0-2(patch_hash=ec66603c60cb6190764d60fa75bd0c0fe532eda91a9c231037db169b8660ac5f)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)))(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9))(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) '@y/protocols': specifier: ^1.0.6-rc.1 - version: 1.0.6-rc.1(@y/y@14.0.0-rc.16) + version: 1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/y': specifier: ^14.0.0-rc.16 - version: 14.0.0-rc.16 + version: 14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9) emoji-mart: specifier: ^5.6.0 version: 5.6.0 @@ -4971,7 +4973,7 @@ importers: version: 3.1.3 lib0: specifier: 1.0.0-rc.13 - version: 1.0.0-rc.13 + version: 1.0.0-rc.13(patch_hash=328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e) prosemirror-highlight: specifier: ^0.15.1 version: 0.15.1(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.41.8) @@ -23042,29 +23044,29 @@ snapshots: dependencies: '@types/node': 20.19.39 - '@y/prosemirror@2.0.0-2(patch_hash=6d18e9340ac9dddf315d155e6f37a101c4a51c6ad4aef3dfb6485f6903a12cc8)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16))(@y/y@14.0.0-rc.16)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)': + '@y/prosemirror@2.0.0-2(patch_hash=ec66603c60cb6190764d60fa75bd0c0fe532eda91a9c231037db169b8660ac5f)(@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)))(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9))(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)': dependencies: - '@y/protocols': 1.0.6-rc.1(@y/y@14.0.0-rc.16) - '@y/y': 14.0.0-rc.16 - lib0: 1.0.0-rc.13 + '@y/protocols': 1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) + '@y/y': 14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9) + lib0: 1.0.0-rc.13(patch_hash=328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e) prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 prosemirror-view: 1.41.8 - '@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16)': + '@y/protocols@1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9))': dependencies: - '@y/y': 14.0.0-rc.16 - lib0: 1.0.0-rc.13 + '@y/y': 14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9) + lib0: 1.0.0-rc.13(patch_hash=328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e) - '@y/websocket@4.0.0-rc.2(@y/y@14.0.0-rc.16)': + '@y/websocket@4.0.0-rc.2(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9))': dependencies: - '@y/protocols': 1.0.6-rc.1(@y/y@14.0.0-rc.16) - '@y/y': 14.0.0-rc.16 - lib0: 1.0.0-rc.13 + '@y/protocols': 1.0.6-rc.1(@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) + '@y/y': 14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9) + lib0: 1.0.0-rc.13(patch_hash=328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e) - '@y/y@14.0.0-rc.16': + '@y/y@14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)': dependencies: - lib0: 1.0.0-rc.13 + lib0: 1.0.0-rc.13(patch_hash=328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e) '@yarnpkg/lockfile@1.1.0': {} @@ -26021,7 +26023,7 @@ snapshots: dependencies: isomorphic.js: 0.2.5 - lib0@1.0.0-rc.13: {} + lib0@1.0.0-rc.13(patch_hash=328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e): {} lie@3.3.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1f6cd63d35..be06c435d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,3 +35,5 @@ allowBuilds: leveldown: false patchedDependencies: "@y/prosemirror@2.0.0-2": "patches/@y__prosemirror@2.0.0-2.patch" + '@y/y@14.0.0-rc.16': patches/@y__y@14.0.0-rc.16.patch + lib0@1.0.0-rc.13: patches/lib0@1.0.0-rc.13.patch diff --git a/scripts/patch-lib0.sh b/scripts/patch-lib0.sh new file mode 100755 index 0000000000..d29ccca6e7 --- /dev/null +++ b/scripts/patch-lib0.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Regenerates the pnpm patch for lib0 from a local build. +# +# Usage: +# ./scripts/patch-lib0.sh [path-to-lib0] +# +# Defaults to ../lib0 relative to this repo root. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BLOCKNOTE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_LIB0="${1:-$(cd "$BLOCKNOTE_ROOT/../lib0" && pwd)}" + +if [[ ! -d "$LOCAL_LIB0/src" ]]; then + echo "ERROR: Cannot find lib0 at $LOCAL_LIB0" + echo "Pass the path as an argument: $0 /path/to/lib0" + exit 1 +fi + +echo "==> Using local lib0 at: $LOCAL_LIB0" +echo "==> BlockNote root: $BLOCKNOTE_ROOT" + +# 0. Build lib0 so dist/ is up to date +echo "==> Building lib0 (npm run dist) ..." +(cd "$LOCAL_LIB0" && npm run dist) + +PATCH_DIR="$BLOCKNOTE_ROOT/node_modules/.pnpm_patches/lib0@1.0.0-rc.13" + +# 1. Clean up any leftover patch dir, then start fresh +if [[ -d "$PATCH_DIR" ]]; then + echo "==> Cleaning up old patch dir ..." + rm -rf "$PATCH_DIR" +fi + +echo "==> Running pnpm patch lib0@1.0.0-rc.13 ..." +cd "$BLOCKNOTE_ROOT" +pnpm patch lib0@1.0.0-rc.13 + +echo "==> Patch temp dir: $PATCH_DIR" + +# 2. Replace src/ with local build +echo "==> Replacing src/ ..." +rm -rf "$PATCH_DIR/src" +cp -R "$LOCAL_LIB0/src" "$PATCH_DIR/src" + +# 3. Replace dist/ with local build (.d.ts files) +echo "==> Replacing dist/ ..." +rm -rf "$PATCH_DIR/dist" +cp -R "$LOCAL_LIB0/dist" "$PATCH_DIR/dist" + +# 4. Update package.json in the patch dir +echo "==> Updating package.json ..." +node -e " +const fs = require('fs'); +const orig = JSON.parse(fs.readFileSync('$PATCH_DIR/package.json', 'utf8')); +const local = JSON.parse(fs.readFileSync('$LOCAL_LIB0/package.json', 'utf8')); + +// Keep the original version so pnpm doesn't try to fetch a different version from registry +orig.version = '1.0.0-rc.13'; + +// Update exports +orig.exports = local.exports; + +// Update files list +orig.files = local.files; + +// Update type/sideEffects if present +if (local.type) orig.type = local.type; +if ('sideEffects' in local) orig.sideEffects = local.sideEffects; + +// Update bin if present +if (local.bin) orig.bin = local.bin; + +fs.writeFileSync('$PATCH_DIR/package.json', JSON.stringify(orig, null, 2) + '\n'); +console.log(' package.json updated'); +" + +# 5. Commit the patch +echo "" +echo "==> Running pnpm patch-commit ..." +pnpm patch-commit "$PATCH_DIR" + +echo "" +echo "==> Done! Patch regenerated at patches/lib0@1.0.0-rc.13.patch" diff --git a/scripts/patch-y-prosemirror.sh b/scripts/patch-y-prosemirror.sh index 97c0b63b42..8bb69fa990 100755 --- a/scripts/patch-y-prosemirror.sh +++ b/scripts/patch-y-prosemirror.sh @@ -92,10 +92,5 @@ echo "" echo "==> Running pnpm patch-commit ..." pnpm patch-commit "$PATCH_DIR" -# 7. Prune stale patch copies from the store -echo "" -echo "==> Pruning stale store entries ..." -pnpm store prune - echo "" echo "==> Done! Patch regenerated at patches/@y__prosemirror@2.0.0-2.patch" diff --git a/scripts/patch-yjs.sh b/scripts/patch-yjs.sh new file mode 100755 index 0000000000..a8be7342c7 --- /dev/null +++ b/scripts/patch-yjs.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Regenerates the pnpm patch for @y/y (yjs) from a local build. +# +# Usage: +# ./scripts/patch-yjs.sh [path-to-yjs] +# +# Defaults to ../yjs relative to this repo root. + +set -euo pipefail + +# Version that is actually installed in this repo (pnpm patches the installed +# version). The local ../yjs checkout may be a newer rc; we still pin to this. +YJS_PKG="@y/y" +YJS_VERSION="14.0.0-rc.16" + +# pnpm keeps the scope path for the temp patch dir (e.g. .pnpm_patches/@y/y@VER) +# but escapes "/" to "__" for the committed patch file name. +YJS_PATCH_DIR_NAME="$YJS_PKG@$YJS_VERSION" +YJS_PATCH_FILE_NAME="@y__y@$YJS_VERSION.patch" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BLOCKNOTE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_YJS="${1:-$(cd "$BLOCKNOTE_ROOT/../yjs" && pwd)}" + +if [[ ! -d "$LOCAL_YJS/src" ]]; then + echo "ERROR: Cannot find yjs at $LOCAL_YJS" + echo "Pass the path as an argument: $0 /path/to/yjs" + exit 1 +fi + +echo "==> Using local yjs at: $LOCAL_YJS" +echo "==> BlockNote root: $BLOCKNOTE_ROOT" + +# 0. Build yjs so dist/ is up to date +echo "==> Building yjs (npm run dist) ..." +(cd "$LOCAL_YJS" && npm run dist) + +PATCH_DIR="$BLOCKNOTE_ROOT/node_modules/.pnpm_patches/$YJS_PATCH_DIR_NAME" + +# 1. Clean up any leftover patch dir, then start fresh +if [[ -d "$PATCH_DIR" ]]; then + echo "==> Cleaning up old patch dir ..." + rm -rf "$PATCH_DIR" +fi + +echo "==> Running pnpm patch $YJS_PKG@$YJS_VERSION ..." +cd "$BLOCKNOTE_ROOT" +pnpm patch "$YJS_PKG@$YJS_VERSION" + +echo "==> Patch temp dir: $PATCH_DIR" + +# 2. Replace src/ with local build +echo "==> Replacing src/ ..." +rm -rf "$PATCH_DIR/src" +cp -R "$LOCAL_YJS/src" "$PATCH_DIR/src" + +# 3. Replace dist/ with local build (.d.ts files) +echo "==> Replacing dist/ ..." +rm -rf "$PATCH_DIR/dist" +cp -R "$LOCAL_YJS/dist" "$PATCH_DIR/dist" + +# 4. Replace tests/ (testHelper is part of the published exports) +if [[ -d "$LOCAL_YJS/tests" ]]; then + echo "==> Replacing tests/ ..." + rm -rf "$PATCH_DIR/tests" + cp -R "$LOCAL_YJS/tests" "$PATCH_DIR/tests" +fi + +# 5. Copy top-level type decls referenced by the package (e.g. global.d.ts) +if [[ -f "$LOCAL_YJS/global.d.ts" ]]; then + echo "==> Copying global.d.ts ..." + cp "$LOCAL_YJS/global.d.ts" "$PATCH_DIR/global.d.ts" +fi + +# 6. Update package.json in the patch dir +echo "==> Updating package.json ..." +node -e " +const fs = require('fs'); +const orig = JSON.parse(fs.readFileSync('$PATCH_DIR/package.json', 'utf8')); +const local = JSON.parse(fs.readFileSync('$LOCAL_YJS/package.json', 'utf8')); + +// Keep the original (installed) version so pnpm doesn't try to fetch a +// different version from the registry. +orig.version = '$YJS_VERSION'; + +// Update exports (this package is exports-based, no main/module) +if (local.exports) orig.exports = local.exports; + +// Update files list +if (local.files) orig.files = local.files; + +// Update type/sideEffects if present +if (local.type) orig.type = local.type; +if ('sideEffects' in local) orig.sideEffects = local.sideEffects; + +// Update bin if present +if (local.bin) orig.bin = local.bin; + +fs.writeFileSync('$PATCH_DIR/package.json', JSON.stringify(orig, null, 2) + '\n'); +console.log(' package.json updated'); +" + +# 7. Commit the patch +echo "" +echo "==> Running pnpm patch-commit ..." +pnpm patch-commit "$PATCH_DIR" + +echo "" +echo "==> Done! Patch regenerated at patches/$YJS_PATCH_FILE_NAME" diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index 142a5e7771..4ff3003b15 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -34,6 +34,44 @@ "runsBefore": [ "file", ], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-audio", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-audio", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-audio", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-audio", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -70,6 +108,44 @@ "parse": [Function], "parseContent": [Function], "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-bulletListItem", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-bulletListItem", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-bulletListItem", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-bulletListItem", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -113,6 +189,44 @@ "runsBefore": [ "bulletListItem", ], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-checkListItem", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-checkListItem", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-checkListItem", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-checkListItem", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -140,6 +254,44 @@ "parse": [Function], "parseContent": [Function], "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": true, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-codeBlock", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-codeBlock", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": true, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-codeBlock", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-codeBlock", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -170,6 +322,44 @@ "node": null, "parse": [Function], "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-customParagraph", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-customParagraph", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-customParagraph", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-customParagraph", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -189,6 +379,44 @@ "node": null, "parse": [Function], "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-divider", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-divider", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-divider", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-divider", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -221,6 +449,44 @@ "node": null, "parse": [Function], "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-file", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-file", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-file", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-file", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -275,6 +541,44 @@ "runsBefore": [ "toggleListItem", ], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-heading", + "parseHTML": [Function], + "priority": 121, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-heading", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-heading", + "parseHTML": [Function], + "priority": 121, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-heading", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -326,6 +630,44 @@ "runsBefore": [ "file", ], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-image", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-image", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-image", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-image", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -366,6 +708,44 @@ "parse": [Function], "parseContent": [Function], "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-numberedListItem", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-numberedListItem", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-numberedListItem", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-numberedListItem", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -380,6 +760,44 @@ "node": null, "parse": [Function], "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-pageBreak", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-pageBreak", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-pageBreak", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-pageBreak", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -419,6 +837,44 @@ "default", "heading", ], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-paragraph", + "parseHTML": [Function], + "priority": 131, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-paragraph", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-paragraph", + "parseHTML": [Function], + "priority": 131, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-paragraph", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -445,6 +901,44 @@ "node": null, "parse": [Function], "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-quote", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-quote", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-quote", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-quote", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -474,6 +968,44 @@ "implementation": { "node": null, "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-simpleCustomParagraph", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-simpleCustomParagraph", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-simpleCustomParagraph", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-simpleCustomParagraph", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -516,6 +1048,44 @@ "implementation": { "node": null, "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-simpleImage", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-simpleImage", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-simpleImage", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-simpleImage", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -536,6 +1106,44 @@ "implementation": { "node": null, "render": [Function], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "table", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-table", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-table", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "table", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-table", + "parseHTML": [Function], + "priority": 101, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-table", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -575,6 +1183,44 @@ "runsBefore": [ "bulletListItem", ], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-toggleListItem", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-toggleListItem", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "inline*", + "defining": true, + "group": "suggestionBlockContent", + "isolating": false, + "name": "suggestion-toggleListItem", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-toggleListItem", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, }, @@ -626,6 +1272,44 @@ "runsBefore": [ "file", ], + "suggestionNode": _Node { + "child": _Node { + "child": null, + "config": { + "addAttributes": [Function], + "addOptions": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-video", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-video", + "parent": null, + "type": "node", + }, + "config": { + "addAttributes": [Function], + "code": false, + "content": "", + "defining": true, + "group": "suggestionBlockContent", + "isolating": true, + "name": "suggestion-video", + "parseHTML": [Function], + "priority": 111, + "renderHTML": [Function], + "selectable": false, + }, + "name": "suggestion-video", + "parent": null, + "type": "node", + }, "toExternalHTML": [Function], }, },