diff --git a/examples/07-collaboration/11-yhub/src/App.tsx b/examples/07-collaboration/11-yhub/src/App.tsx index 2008ff54f3..d87977cb2e 100644 --- a/examples/07-collaboration/11-yhub/src/App.tsx +++ b/examples/07-collaboration/11-yhub/src/App.tsx @@ -3,7 +3,11 @@ import "@blocknote/core/fonts/inter.css"; import "@blocknote/mantine/style.css"; import { BlockNoteView } from "@blocknote/mantine"; import { useCreateBlockNote } from "@blocknote/react"; -import { Awareness } from "@y/protocols/awareness"; +import { + Awareness, + encodeAwarenessUpdate, + applyAwarenessUpdate, +} from "@y/protocols/awareness"; import { withCollaboration } from "@blocknote/core/y"; import * as Y from "@y/y"; @@ -80,6 +84,36 @@ function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) { setupTwoWaySync(doc, doc2); setupTwoWaySync(suggestingDoc, suggestionModeDoc); +// Sync awareness states so cursors show up across editors +function setupAwarenessSync(a1: Awareness, a2: Awareness) { + // Initial sync + applyAwarenessUpdate( + a2, + encodeAwarenessUpdate(a1, [a1.clientID]), + "sync", + ); + applyAwarenessUpdate( + a1, + encodeAwarenessUpdate(a2, [a2.clientID]), + "sync", + ); + + a1.on("update", ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => { + const update = encodeAwarenessUpdate(a1, added.concat(updated).concat(removed)); + applyAwarenessUpdate(a2, update, "sync"); + }); + + a2.on("update", ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => { + const update = encodeAwarenessUpdate(a2, added.concat(updated).concat(removed)); + applyAwarenessUpdate(a1, update, "sync"); + }); +} + +setupAwarenessSync(provider.awareness, provider2.awareness); +setupAwarenessSync(suggestingProvider.awareness, suggestionModeProvider.awareness); +setupAwarenessSync(provider.awareness, suggestingProvider.awareness); +setupAwarenessSync(provider.awareness, suggestionModeProvider.awareness); + function Editor({ fragment, provider, diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index afb9222e64..2bdbdcb1ed 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -33,8 +33,8 @@ BASIC STYLES transition: all 0.2s; /* Workaround for selection issue on Chrome, see #1588 and also here: https://discuss.prosemirror.net/t/mouse-down-selection-behaviour-different-on-chrome/8426 - The :before element causes the selection to be set in the wrong place vs - other browsers. Setting no height fixes this, while list item indicators are + The :before element causes the selection to be set in the wrong place vs + other browsers. Setting no height fixes this, while list item indicators are still displayed fine as overflow is not hidden. */ height: 0; overflow: visible; @@ -740,3 +740,58 @@ div[data-type="modification"] { text-decoration: line-through; text-decoration-thickness: 1px; } + +/* Suggestion decoration styling (data-diff-type drives everything) */ +[data-diff-type="inline-insert"], +[data-diff-type="block-insert"] { + background-color: color-mix( + in srgb, + var(--author-color, #28a745) 22%, + transparent + ); + text-decoration: underline; + text-decoration-color: var(--author-color, #28a745); + text-decoration-thickness: 2px; + border-radius: 2px; +} + +[data-diff-type="inline-delete"], +[data-diff-type="block-delete"] { + background-color: color-mix( + in srgb, + var(--author-color, #dc3545) 14%, + transparent + ); + text-decoration: line-through; + text-decoration-color: var(--author-color, #dc3545); + color: #555; + border-radius: 2px; +} +[data-diff-type="block-delete"] { + padding: 2px 4px; + margin: 2px 0; +} +[data-diff-type="block-delete"] * { + text-decoration: line-through; + text-decoration-color: var(--author-color, #dc3545); + color: #555; +} +[data-diff-type="inline-delete"] { + padding: 0 1px; +} + +[data-diff-type="inline-update"], +[data-diff-type="block-update"] { + outline: 1.5px dashed var(--author-color, #ffc107); + outline-offset: 1px; + border-radius: 2px; +} + +[data-diff-type="block-delete"] strong, +[data-diff-type="inline-delete"] strong { + font-weight: normal; +} +[data-diff-type="block-delete"] em, +[data-diff-type="inline-delete"] em { + font-style: normal; +} diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 6df3e68aa4..577999f736 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -197,8 +197,7 @@ export function addNodeAndExtensionsToSpec< // Gets the block const block = getBlockFromPos( props.getPos, - editor, - this.editor, + props.view.state.doc, blockConfig.type, ); // Gets the custom HTML attributes for `blockContent` nodes diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index eed8cf9fa3..584badc819 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -1,18 +1,12 @@ -import { Attribute, Attributes, Editor, Node } from "@tiptap/core"; +import { Attribute, Attributes, Node } from "@tiptap/core"; +import type { Node as PMNode } from "prosemirror-model"; +import { getBlock } from "../../api/blockManipulation/getBlock/getBlock.js"; import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js"; -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; -import { InlineContentSchema } from "../inlineContent/types.js"; import { PropSchema, Props } from "../propTypes.js"; -import { StyleSchema } from "../styles/types.js"; -import { - BlockConfig, - BlockSchemaWithBlock, - LooseBlockSpec, - SpecificBlock, -} from "./types.js"; +import { BlockConfig, BlockFromConfig, LooseBlockSpec } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. @@ -85,22 +79,16 @@ export function propsToAttributes(propSchema: PropSchema): Attributes { export function getBlockFromPos< BType extends string, Config extends BlockConfig, - BSchema extends BlockSchemaWithBlock, - I extends InlineContentSchema, - S extends StyleSchema, ->( - getPos: () => number | undefined, - editor: BlockNoteEditor, - tipTapEditor: Editor, - type: BType, -) { +>(getPos: () => number | undefined, doc: PMNode, type: BType) { + // TODO is there a cleaner implementation of this? Probably... const pos = getPos(); // Gets position of the node if (pos === undefined) { throw new Error("Cannot find node position"); } + // Gets parent blockContainer node - const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); + const blockContainer = doc.resolve(pos).node(); // Gets block identifier const blockIdentifier = blockContainer.attrs.id; @@ -109,16 +97,14 @@ export function getBlockFromPos< } // Gets the block - const block = editor.getBlock(blockIdentifier)! as SpecificBlock< - BSchema, - BType, - I, - S + const block = getBlock(doc, blockIdentifier) as BlockFromConfig< + Config, + any, + any >; if (block.type !== type) { throw new Error("Block type does not match"); } - return block; } diff --git a/packages/core/src/y/extensions/RelativePositionMapping.test.ts b/packages/core/src/y/extensions/RelativePositionMapping.test.ts index 4594fa7448..ff20bcc21f 100644 --- a/packages/core/src/y/extensions/RelativePositionMapping.test.ts +++ b/packages/core/src/y/extensions/RelativePositionMapping.test.ts @@ -27,7 +27,7 @@ function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) { }); } -describe("RelativePositionMapping (@y/y)", () => { +describe.skip("RelativePositionMapping (@y/y)", () => { it("should return the same position when no changes are made", () => { const ydoc = new Y.Doc(); const remoteYdoc = new Y.Doc(); diff --git a/packages/core/src/y/extensions/RelativePositionMapping.ts b/packages/core/src/y/extensions/RelativePositionMapping.ts index 95b36ba63d..1af7881152 100644 --- a/packages/core/src/y/extensions/RelativePositionMapping.ts +++ b/packages/core/src/y/extensions/RelativePositionMapping.ts @@ -23,7 +23,7 @@ export const RelativePositionMappingExtension = createExtension( position + (side === "right" ? 1 : -1), ), ySyncPluginState.ytype, - ySyncPluginState.attributionManager, + ySyncPluginState.attributionManager || undefined, ); return () => { @@ -32,8 +32,8 @@ export const RelativePositionMappingExtension = createExtension( ) as typeof ySyncPluginState; const pos = posStore( editor.prosemirrorState.doc, - curYSyncPluginState.ytype, - curYSyncPluginState.attributionManager, + curYSyncPluginState.ytype || undefined, + curYSyncPluginState.attributionManager || undefined, ); // This can happen if the element is garbage collected diff --git a/packages/core/src/y/extensions/YSuggestions.test.ts b/packages/core/src/y/extensions/YSuggestions.test.ts new file mode 100644 index 0000000000..0aa1e7baeb --- /dev/null +++ b/packages/core/src/y/extensions/YSuggestions.test.ts @@ -0,0 +1,655 @@ +import { describe, expect, it } from "vitest"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { Fragment } from "prosemirror-model"; +import { Decoration } from "prosemirror-view"; +import type { Diff } from "@y/prosemirror"; +import { + defaultMapDiffToDecorations, + findWrappingPath, + wrapFragmentInDoc, +} from "./YSuggestions.js"; + +/** + * @vitest-environment jsdom + */ + +describe("findWrappingPath", () => { + it("finds path [doc, blockGroup, blockContainer] for a checkListItem node", () => { + const editor = BlockNoteEditor.create(); + const schema = editor.pmSchema; + + const checkListItem = schema.nodes.checkListItem.create( + { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + checked: false, + }, + [schema.text("ds")], + ); + + const path = findWrappingPath(schema, checkListItem); + + expect(path).not.toBeNull(); + expect(path!.map((t) => t.name)).toEqual([ + "doc", + "blockGroup", + "blockContainer", + ]); + }); + + it("finds path [doc, blockGroup, blockContainer] for a paragraph node", () => { + const editor = BlockNoteEditor.create(); + const schema = editor.pmSchema; + + const paragraph = schema.nodes.paragraph.create(null, [ + schema.text("hello"), + ]); + + const path = findWrappingPath(schema, paragraph); + + expect(path).not.toBeNull(); + expect(path!.map((t) => t.name)).toEqual([ + "doc", + "blockGroup", + "blockContainer", + ]); + }); +}); + +describe("wrapFragmentInDoc", () => { + it("wraps a fragment with a single checkListItem", () => { + const editor = BlockNoteEditor.create(); + const schema = editor.pmSchema; + + const checkListItem = schema.nodes.checkListItem.create( + { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + checked: false, + }, + [schema.text("ds")], + ); + const fragment = Fragment.from(checkListItem); + + const result = wrapFragmentInDoc(fragment, schema); + + expect(result).not.toBeNull(); + expect(result!.type.name).toBe("doc"); + // Walk down to verify the content + const blockGroup = result!.firstChild!; + expect(blockGroup.type.name).toBe("blockGroup"); + expect(blockGroup.childCount).toBe(1); + const bc = blockGroup.firstChild!; + expect(bc.type.name).toBe("blockContainer"); + expect(bc.firstChild!.type.name).toBe("checkListItem"); + expect(bc.firstChild!.textContent).toBe("ds"); + }); + + it("wraps a fragment with multiple block content nodes", () => { + const editor = BlockNoteEditor.create(); + const schema = editor.pmSchema; + + const item1 = schema.nodes.checkListItem.create( + { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + checked: false, + }, + [schema.text("first")], + ); + const item2 = schema.nodes.paragraph.create(null, [ + schema.text("second"), + ]); + const fragment = Fragment.from([item1, item2]); + + const result = wrapFragmentInDoc(fragment, schema); + + expect(result).not.toBeNull(); + expect(result!.type.name).toBe("doc"); + const blockGroup = result!.firstChild!; + expect(blockGroup.type.name).toBe("blockGroup"); + // Should have two blockContainers + expect(blockGroup.childCount).toBe(2); + expect(blockGroup.child(0).firstChild!.type.name).toBe("checkListItem"); + expect(blockGroup.child(0).firstChild!.textContent).toBe("first"); + expect(blockGroup.child(1).firstChild!.type.name).toBe("paragraph"); + expect(blockGroup.child(1).firstChild!.textContent).toBe("second"); + }); + + it("returns null for an empty fragment", () => { + const editor = BlockNoteEditor.create(); + const result = wrapFragmentInDoc(Fragment.empty, editor.pmSchema); + expect(result).toBeNull(); + }); + +}); + +describe("defaultMapDiffToDecorations", () => { + /** + * Helper: create a BlockNoteEditor and build its initial ProseMirror doc. + * Returns the editor, its schema, and the doc. + */ + const setup = () => { + const editor = BlockNoteEditor.create(); + const schema = editor.pmSchema; + const doc = editor.prosemirrorView!.state.doc; + return { editor, schema, doc }; + }; + + const baseAttribution = { + type: "added" as const, + authorIds: ["user-1"], + timestamp: Date.now(), + }; + + // ── inline-insert ─────────────────────────────────────────────────── + it("returns an inline decoration for inline-insert", () => { + const { editor, schema, doc } = setup(); + + const diff: Diff = { + type: "inline-insert", + from: 3, + to: 5, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + + // Should be a single Decoration (not array) + expect(result).not.toBeNull(); + const deco = result as Decoration; + expect(deco.from).toBe(3); + expect(deco.to).toBe(5); + expect((deco as any).type.attrs.class).toContain("pm-suggest--inline-insert"); + }); + + // ── inline-update ────────────────────────────────────────────────── + it("returns an inline decoration for inline-update", () => { + const { editor, schema, doc } = setup(); + + const diff: Diff = { + type: "inline-update", + from: 3, + to: 5, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + + expect(result).not.toBeNull(); + const deco = result as Decoration; + expect(deco.from).toBe(3); + expect(deco.to).toBe(5); + expect((deco as any).type.attrs.class).toContain("pm-suggest--inline-update"); + }); + + // ── block-update ─────────────────────────────────────────────────── + it("returns a node decoration for block-update", () => { + const { editor, schema, doc } = setup(); + + // Target the first blockContainer in the doc + let from = -1; + let to = -1; + doc.descendants((node, pos) => { + if (from === -1 && node.type.name === "blockContainer") { + from = pos; + to = pos + node.nodeSize; + return false; + } + return undefined; + }); + + const diff: Diff = { + type: "block-update", + from, + to, + attribution: baseAttribution, + attributes: { level: "2" }, + previousAttributes: { level: "1" }, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + + expect(result).not.toBeNull(); + const deco = result as Decoration; + expect(deco.from).toBe(from); + expect(deco.to).toBe(to); + expect((deco as any).type.attrs.class).toContain("pm-suggest--block-update"); + // Title should contain the attribute change summary + expect((deco as any).type.attrs.title).toContain("level: 1 → 2"); + }); + + // ── block-insert (single node) ──────────────────────────────────── + it("returns a node decoration for block-insert spanning a single node", () => { + const { editor, schema, doc } = setup(); + + let from = -1; + let to = -1; + doc.descendants((node, pos) => { + if (from === -1 && node.type.name === "blockContainer") { + from = pos; + to = pos + node.nodeSize; + return false; + } + return undefined; + }); + + const diff: Diff = { + type: "block-insert", + from, + to, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + + expect(result).not.toBeNull(); + const deco = result as Decoration; + expect(deco.from).toBe(from); + expect(deco.to).toBe(to); + expect((deco as any).type.attrs.class).toContain("pm-suggest--block-insert"); + }); + + // ── inline-delete with plain text ───────────────────────────────── + it("returns a widget decoration for inline-delete with text content", () => { + const { editor, schema, doc } = setup(); + + const fragment = Fragment.from(schema.text("deleted text")); + + const diff: Diff = { + type: "inline-delete", + from: 3, + to: 3, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + + expect(result).not.toBeNull(); + const deco = result as Decoration; + expect(deco.from).toBe(3); + // Widget decorations have from === to + expect(deco.to).toBe(3); + // Invoke the widget toDOM to get the rendered element + const el = (deco as any).type.toDOM() as HTMLElement; + expect(el.tagName).toBe("SPAN"); + expect(el.className).toContain("pm-suggest--delete"); + expect(el.getAttribute("data-diff-type")).toBe("inline-delete"); + expect(el.textContent).toContain("deleted text"); + expect(el.contentEditable).toBe("false"); + }); + + // ── inline-delete with bold text ────────────────────────────────── + it("preserves marks (bold) in inline-delete rendering", () => { + const { editor, schema, doc } = setup(); + + const boldMark = schema.marks.bold.create(); + const fragment = Fragment.from( + schema.text("bold deleted", [boldMark]), + ); + + const diff: Diff = { + type: "inline-delete", + from: 3, + to: 3, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + const el = (result as any).type.toDOM() as HTMLElement; + + expect(el.textContent).toContain("bold deleted"); + // The rendered HTML should contain a element for the bold mark + expect(el.querySelector("strong")).not.toBeNull(); + }); + + // ── inline-delete with empty fragment ───────────────────────────── + it("returns a widget for inline-delete with empty content", () => { + const { editor, schema, doc } = setup(); + + const diff: Diff = { + type: "inline-delete", + from: 3, + to: 3, + content: Fragment.empty, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + + expect(result).not.toBeNull(); + const el = (result as any).type.toDOM() as HTMLElement; + expect(el.tagName).toBe("SPAN"); + expect(el.textContent).toBe(""); + }); + + // ── block-delete with a paragraph ───────────────────────────────── + it("returns a widget decoration for block-delete with paragraph content", () => { + const { editor, schema, doc } = setup(); + + const paragraph = schema.nodes.paragraph.create(null, [ + schema.text("removed block"), + ]); + const fragment = Fragment.from(paragraph); + + const diff: Diff = { + type: "block-delete", + from: 0, + to: 0, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + + expect(result).not.toBeNull(); + const deco = result as Decoration; + expect(deco.from).toBe(0); + const el = (deco as any).type.toDOM() as HTMLElement; + expect(el.tagName).toBe("DIV"); + expect(el.className).toContain("pm-suggest--delete"); + expect(el.getAttribute("data-diff-type")).toBe("block-delete"); + expect(el.textContent).toContain("removed block"); + expect(el.contentEditable).toBe("false"); + }); + + // ── block-delete with empty fragment ────────────────────────────── + it("returns a widget for block-delete with empty content", () => { + const { editor, schema, doc } = setup(); + + const diff: Diff = { + type: "block-delete", + from: 0, + to: 0, + content: Fragment.empty, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + + expect(result).not.toBeNull(); + const el = (result as any).type.toDOM() as HTMLElement; + expect(el.tagName).toBe("DIV"); + expect(el.textContent).toBe(""); + }); + + // ── block-delete with heading ───────────────────────────────────── + it("renders block-delete with a heading node", () => { + const { editor, schema, doc } = setup(); + + const heading = schema.nodes.heading.create( + { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + level: 2, + }, + [schema.text("Deleted Heading")], + ); + const fragment = Fragment.from(heading); + + const diff: Diff = { + type: "block-delete", + from: 0, + to: 0, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + const el = (result as any).type.toDOM() as HTMLElement; + + expect(el.textContent).toContain("Deleted Heading"); + expect(el.getAttribute("data-diff-type")).toBe("block-delete"); + }); + + // ── block-delete with checkListItem (should include checkbox) ───── + it("renders block-delete checkListItem with checkbox", () => { + const { editor, schema, doc } = setup(); + + const checkListItem = schema.nodes.checkListItem.create( + { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + checked: false, + }, + [schema.text("task item")], + ); + const fragment = Fragment.from(checkListItem); + + const diff: Diff = { + type: "block-delete", + from: 0, + to: 0, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + const el = (result as any).type.toDOM() as HTMLElement; + + expect(el.textContent).toContain("task item"); + expect(el.querySelector("input[type='checkbox']")).not.toBeNull(); + }); + + // ── block-delete with a blockContainer fragment ──────────────────── + it("renders block-delete when fragment is a blockContainer", () => { + const { editor, schema, doc } = setup(); + + // In real Yjs diffs, the fragment may be a blockContainer node + const checkListItem = schema.nodes.checkListItem.create( + { + backgroundColor: "default", + textColor: "default", + textAlignment: "left", + checked: true, + }, + [schema.text("a task")], + ); + const blockContainer = schema.nodes.blockContainer.createAndFill( + { id: "test-id" }, + checkListItem, + )!; + const fragment = Fragment.from(blockContainer); + + const diff: Diff = { + type: "block-delete", + from: 0, + to: 0, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + const el = (result as any).type.toDOM() as HTMLElement; + + expect(el.textContent).toContain("a task"); + expect(el.querySelector("input[type='checkbox']")).not.toBeNull(); + }); + + + + // ── block-delete with a single table cell (real-world column delete) ── + it("renders block-delete with a single tableCell without extra wrapper", () => { + const { editor, schema, doc } = setup(); + + // Each cell in a column delete is a separate diff with one tableCell + const cell = schema.nodes.tableCell.create(null, [ + schema.nodes.tableParagraph.create(null, [schema.text("cell A")]), + ]); + const fragment = Fragment.from(cell); + + const diff: Diff = { + type: "block-delete", + from: 0, + to: 0, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + const el = (result as any).type.toDOM() as HTMLElement; + + // Attributes applied directly to the , no wrapper div + expect(el.tagName).toBe("TD"); + expect(el.className).toContain("pm-suggest--delete"); + expect(el.textContent).toContain("cell A"); + }); + + // ── block-delete with multiple table cells ─────────────────────────── + it("renders block-delete with multiple tableCells in a wrapper", () => { + const { editor, schema, doc } = setup(); + + const cell1 = schema.nodes.tableCell.create(null, [ + schema.nodes.tableParagraph.create(null, [schema.text("cell A")]), + ]); + const cell2 = schema.nodes.tableCell.create(null, [ + schema.nodes.tableParagraph.create(null, [schema.text("cell B")]), + ]); + const fragment = Fragment.from([cell1, cell2]); + + const diff: Diff = { + type: "block-delete", + from: 0, + to: 0, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + const el = (result as any).type.toDOM() as HTMLElement; + + // Multiple cells need a wrapper + expect(el.tagName).toBe("DIV"); + expect(el.textContent).toContain("cell A"); + expect(el.textContent).toContain("cell B"); + }); + + // ── author color and title propagation ──────────────────────────── + it("sets author color and hover title on inline-delete widgets", () => { + const { editor, schema, doc } = setup(); + + const fragment = Fragment.from(schema.text("colored")); + + const diff: Diff = { + type: "inline-delete", + from: 3, + to: 3, + content: fragment, + attribution: { + type: "removed", + authorIds: ["alice", "bob"], + timestamp: 1700000000000, + }, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ + diff, + doc, + schema, + index: 0, + color: "#ff0000", + }); + + const el = (result as any).type.toDOM() as HTMLElement; + expect(el.getAttribute("data-diff-user-id")).toBe("alice,bob"); + expect(el.style.getPropertyValue("--author-color")).toBe("#ff0000"); + expect(el.getAttribute("title")).toContain("alice"); + expect(el.getAttribute("title")).toContain("bob"); + }); + + // ── inline-delete with link mark ────────────────────────────────── + it("preserves link marks in inline-delete rendering", () => { + const { editor, schema, doc } = setup(); + + const linkMark = schema.marks.link.create({ + href: "https://example.com", + }); + const fragment = Fragment.from( + schema.text("click here", [linkMark]), + ); + + const diff: Diff = { + type: "inline-delete", + from: 3, + to: 3, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + const el = (result as any).type.toDOM() as HTMLElement; + + expect(el.textContent).toContain("click here"); + // Should contain an anchor tag + expect(el.querySelector("a")).not.toBeNull(); + expect(el.querySelector("a")?.getAttribute("href")).toBe( + "https://example.com", + ); + }); + + // ── inline-delete with multiple marks ───────────────────────────── + it("preserves multiple marks (bold + italic) in inline-delete", () => { + const { editor, schema, doc } = setup(); + + const marks = [ + schema.marks.bold.create(), + schema.marks.italic.create(), + ]; + const fragment = Fragment.from(schema.text("styled", marks)); + + const diff: Diff = { + type: "inline-delete", + from: 3, + to: 3, + content: fragment, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + const el = (result as any).type.toDOM() as HTMLElement; + + expect(el.textContent).toContain("styled"); + expect(el.querySelector("strong")).not.toBeNull(); + expect(el.querySelector("em")).not.toBeNull(); + }); + + // ── unknown diff type returns null ──────────────────────────────── + it("returns null for an unknown diff type", () => { + const { editor, schema, doc } = setup(); + + const diff: Diff = { + type: "unknown-type" as any, + from: 0, + to: 0, + attribution: baseAttribution, + }; + + const mapper = defaultMapDiffToDecorations(editor); + const result = mapper({ diff, doc, schema, index: 0 }); + expect(result).toBeNull(); + }); +}); diff --git a/packages/core/src/y/extensions/YSuggestions.ts b/packages/core/src/y/extensions/YSuggestions.ts new file mode 100644 index 0000000000..d568ffb9e6 --- /dev/null +++ b/packages/core/src/y/extensions/YSuggestions.ts @@ -0,0 +1,512 @@ +import { Diff, MapDiffArgs, ySuggestionDecorationPlugin } from "@y/prosemirror"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { Decoration, DecorationAttrs } from "prosemirror-view"; +import { + DOMSerializer, + Fragment, + Node, + NodeType, + Schema, +} from "prosemirror-model"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { prosemirrorSliceToSlicedBlocks } from "../../api/nodeConversions/nodeToBlock.js"; + +/** + * Reconstruct removed content as a non-editable DOM node by serializing the + * Fragment to HTML. Works for inline text and for whole block nodes alike. + */ +export const renderDeletedContent = ( + fragment: Fragment, + schema: Schema, + opts: { authorIds?: string[]; color?: string; title?: string } = {}, +): HTMLElement => { + const serializer = DOMSerializer.fromSchema(schema); + const isBlock = fragment?.firstChild?.isBlock ?? false; + const container = document.createElement(isBlock ? "div" : "span"); + container.className = "pm-suggest pm-suggest--delete"; + container.setAttribute( + "data-diff-type", + isBlock ? "block-delete" : "inline-delete", + ); + if (opts.authorIds?.length) { + container.setAttribute("data-diff-user-id", opts.authorIds.join(",")); + } + if (opts.color) { + container.style.setProperty("--author-color", opts.color); + } + if (opts.title) { + container.setAttribute("title", opts.title); + } + container.contentEditable = "false"; + if (fragment) { + container.appendChild(serializer.serializeFragment(fragment, { document })); + } + return container; +}; + +/** + * Build a human-readable hover title from diff attribution. + */ +const hoverTitle = (diff: Diff): string => { + const parts = []; + const authorIds = diff.attribution.authorIds; + if (authorIds.length) { + parts.push(authorIds.join(", ")); + } + if (diff.attribution.timestamp) { + parts.push(new Date(diff.attribution.timestamp).toLocaleString()); + } + const typeLabel = diff.type.replace("-", " "); + if (parts.length) { + return `${typeLabel}: ${parts.join(" — ")}`; + } + return typeLabel; +}; + +/** + * Build a summary string for a block-update diff showing what changed + * (e.g. "level: 1 → 2"). + */ +const blockUpdateSummary = (diff: Diff): string => { + if (diff.type !== "block-update") { + return ""; + } + const attrs = diff.attributes; + const prev = diff.previousAttributes; + if (!attrs) { + return ""; + } + const parts = []; + for (const key of Object.keys(attrs)) { + const newVal = attrs[key]; + const oldVal = prev?.[key]; + if (oldVal !== undefined && oldVal !== newVal) { + parts.push(`${key}: ${oldVal} → ${newVal}`); + } else { + parts.push(`${key}: ${newVal}`); + } + } + return parts.join(", "); +}; + +const decorationAttrs = ( + diff: Diff, + { authorIds, color }: { authorIds: string[]; color?: string }, +): DecorationAttrs => { + const attrs: DecorationAttrs = { + class: `pm-suggest pm-suggest--${diff.type}`, + "data-diff-type": diff.type, + }; + if (authorIds.length) { + attrs["data-diff-user-id"] = authorIds.join(","); + } + if (color) { + attrs.style = `--author-color: ${color}`; + } + // Hover metadata: show author(s), timestamp, and attribute changes + let title = hoverTitle(diff); + const summary = blockUpdateSummary(diff); + if (summary) { + title += ` (${summary})`; + } + attrs.title = title; + return attrs; +}; + +/** + * Find a chain of ancestor node types from `doc` down to a type that can + * contain `child`. Returns the path of NodeTypes (including doc at index 0) + * but NOT including the child's own type. + * + * Uses BFS from the doc type so the shortest wrapping wins. + * Returns null if no valid wrapping path exists. + */ +export const findWrappingPath = ( + schema: Schema, + child: Node, +): NodeType[] | null => { + const docType = schema.topNodeType; + + // BFS: each entry is a path of node types from doc → ... → parent of child + type Path = NodeType[]; + const queue: Path[] = [[docType]]; + const visited = new Set([docType.name]); + + while (queue.length > 0) { + const path = queue.shift()!; + const tip = path[path.length - 1]; + + // Check if this node type can contain `child` anywhere in its content expr + if (tip.contentMatch.matchType(child.type)) { + return path; + } + + // Expand: walk the content match automaton edges to find all node types + // this tip can contain, using the public edgeCount/edge API. + const match = tip.contentMatch; + const seenMatches = new Set(); + const matchQueue = [match]; + while (matchQueue.length > 0) { + const m = matchQueue.shift()!; + if (seenMatches.has(m)) { + continue; + } + seenMatches.add(m); + for (let i = 0; i < m.edgeCount; i++) { + const { type: edgeType, next } = m.edge(i); + if (!visited.has(edgeType.name)) { + visited.add(edgeType.name); + queue.push([...path, edgeType]); + } + // Follow the next match state to discover further edges + matchQueue.push(next); + } + } + } + + return null; +}; + +/** + * Wrap a fragment of nodes into a valid doc by finding the schema-required + * ancestor wrappers for each node. + */ +export const wrapFragmentInDoc = ( + fragment: Fragment, + schema: Schema, +): Node | null => { + const firstChild = fragment.firstChild; + if (!firstChild) { + return null; + } + + // Fast path: if doc can directly contain the fragment, just create it + const directDoc = schema.topNodeType.createAndFill(null, fragment); + if (directDoc) { + return directDoc; + } + + // Find the wrapping path: e.g. [doc, blockGroup, blockContainer] + const path = findWrappingPath(schema, firstChild); + if (!path || path.length === 0) { + return null; + } + + // The last type in the path directly contains our content nodes. + // Wrap each fragment child in that innermost type individually. + const innermostType = path[path.length - 1]; + const wrappedChildren: Node[] = []; + fragment.forEach((child) => { + const wrapped = innermostType.createAndFill(null, child); + if (wrapped) { + wrappedChildren.push(wrapped); + } + }); + + if (!wrappedChildren.length) { + return null; + } + + // Now wrap all children together through the remaining ancestor types, + // from second-to-last back to doc (index 0). + // e.g. [doc, blockGroup, blockContainer]: + // wrappedChildren = [blockContainer(child1), blockContainer(child2)] + // → blockGroup([blockContainer(child1), blockContainer(child2)]) + // → doc(blockGroup(...)) + let currentNodes: Node[] | Node = wrappedChildren; + for (let i = path.length - 2; i >= 0; i--) { + const wrapped = path[i].createAndFill(null, currentNodes); + if (!wrapped) { + return null; + } + currentNodes = wrapped; + } + + const doc = currentNodes as Node; + + return doc; +}; + +/** + * Check whether any node in a fragment has a registered node view. + */ +const fragmentHasNodeView = ( + fragment: Fragment, + nodeViews: Record, +): boolean => { + let found = false; + fragment.forEach((node) => { + if (found) { + return; + } + if (nodeViews[node.type.name]) { + found = true; + return; + } + if (node.content.size > 0 && fragmentHasNodeView(node.content, nodeViews)) { + found = true; + } + }); + return found; +}; + +/** + * Render a deleted fragment (inline or block) as a non-editable DOM element + * using BlockNote's `blocksToFullHTML` pipeline when possible, falling back + * to `DOMSerializer`. + * + * Diff attributes (class, data-diff-type, author info, etc.) are applied + * directly to the rendered root element rather than wrapping in an extra + * container, keeping the DOM flat. + * + * For inline fragments the content is first wrapped in a paragraph node so + * it can be converted to blocks; the rendered inline content is then extracted + * from the `.bn-inline-content` wrapper so it stays inline in the document. + */ +const renderDeletedFragment = ( + fragment: Fragment, + schema: Schema, + editor: BlockNoteEditor, + opts: { + isInline: boolean; + authorIds: string[]; + color?: string; + title: string; + }, +): HTMLElement => { + const tag = opts.isInline ? "span" : "div"; + const diffType = opts.isInline ? "inline-delete" : "block-delete"; + + /** Apply diff attributes to an element in-place. */ + const applyDiffAttrs = (el: HTMLElement) => { + el.classList.add("pm-suggest", "pm-suggest--delete"); + el.setAttribute("data-diff-type", diffType); + if (opts.authorIds.length) { + el.setAttribute("data-diff-user-id", opts.authorIds.join(",")); + } + if (opts.color) { + el.style.setProperty("--author-color", opts.color); + } + el.setAttribute("title", opts.title); + el.contentEditable = "false"; + }; + + if (fragment.size === 0) { + const empty = document.createElement(tag); + applyDiffAttrs(empty); + return empty; + } + + // For inline content, wrap in a paragraph so it forms a valid block tree. + let blockFragment = fragment; + if (opts.isInline) { + const paragraphType = schema.nodes["paragraph"]; + const paragraphNode = paragraphType?.createAndFill(null, fragment); + if (paragraphNode) { + blockFragment = Fragment.from(paragraphNode); + } else { + // Can't wrap in paragraph — fall back to DOMSerializer + const container = document.createElement(tag); + applyDiffAttrs(container); + const serializer = DOMSerializer.fromSchema(schema); + container.appendChild( + serializer.serializeFragment(fragment, { document }), + ); + return container; + } + } + + // Check if the fragment nodes are "inner" nodes that live deeper than + // block content types (e.g. tableCell, tableRow). For these, the + // blocksToFullHTML pipeline would wrap them in unnecessary nesting + // (full table + block wrappers), so we use DOMSerializer directly. + const firstChild = blockFragment.firstChild; + const wrappingPath = firstChild + ? findWrappingPath(schema, firstChild) + : null; + const isSubBlockContent = wrappingPath && wrappingPath.length > 3; + + let rendered: HTMLElement | null = null; + + if (!isSubBlockContent) { + const ghostDoc = wrapFragmentInDoc(blockFragment, schema); + + if (ghostDoc) { + try { + const slicedBlocks = prosemirrorSliceToSlicedBlocks( + ghostDoc.slice(0, ghostDoc.nodeSize - 2), + editor.pmSchema, + ); + const html = editor.blocksToFullHTML(slicedBlocks.blocks); + const temp = document.createElement("div"); + temp.innerHTML = html; + + if (opts.isInline) { + // Extract just the inline content from the block wrapper. + const inlineContentEl = temp.querySelector(".bn-inline-content"); + if (inlineContentEl) { + const span = document.createElement("span"); + while (inlineContentEl.firstChild) { + span.appendChild(inlineContentEl.firstChild); + } + rendered = span; + } else { + // No .bn-inline-content found — use the root element + rendered = temp.firstElementChild as HTMLElement | null; + } + } else { + // Extract the .bn-block-outer element so we don't add an extra + // bn-block-group wrapper — the widget is already inserted inside + // an existing block-group in the document. + const blockOuter = temp.querySelector( + ".bn-block-outer", + ) as HTMLElement | null; + rendered = blockOuter ?? (temp.firstElementChild as HTMLElement | null); + } + } catch (e) { + // prosemirrorSliceToSlicedBlocks doesn't support all node structures. + // Fall through to DOMSerializer fallback. + console.warn( + "[BlockNote] renderDeletedFragment: blocksToFullHTML pipeline failed, falling back to DOMSerializer", + e, + ); + } + } + } + + if (!rendered) { + // Fallback: use DOMSerializer for sub-block nodes (tableCell, etc.) + // or when wrapping/conversion failed. + const serializer = DOMSerializer.fromSchema(schema); + const serialized = serializer.serializeFragment(fragment, { document }); + + // If the fragment serializes to a single element, use it directly + // to avoid an extra wrapper (e.g. stays as , not
). + const children = Array.from(serialized.childNodes).filter( + (n): n is HTMLElement => n.nodeType === 1, // ELEMENT_NODE + ); + if (children.length === 1 && serialized.childNodes.length === 1) { + rendered = children[0]; + } else { + const container = document.createElement(tag); + container.appendChild(serialized); + rendered = container; + } + } + + applyDiffAttrs(rendered); + return rendered; +}; + +/** + * Default mapping from a single `Diff` to decoration(s). Returns a `Decoration`, + * an array of them, or `null` to skip. + */ +export const defaultMapDiffToDecorations = + (editor: BlockNoteEditor) => + ({ + diff, + doc, + schema, + index, + color, + attributes = {}, + }: MapDiffArgs): Decoration[] | Decoration | null => { + const authorIds = diff.attribution.authorIds; + const attrs = { + ...decorationAttrs(diff, { authorIds, color }), + ...attributes, + }; + const spec = { diff }; + + switch (diff.type) { + case "inline-insert": + case "inline-update": + return Decoration.inline(diff.from, diff.to, attrs, { + ...spec, + inclusiveStart: false, + inclusiveEnd: true, + }); + + case "block-update": + return Decoration.node(diff.from, diff.to, attrs, spec); + + case "block-insert": { + const $from = doc.resolve(diff.from); + const after = $from.nodeAfter; + if (after && diff.from + after.nodeSize === diff.to) { + return Decoration.node(diff.from, diff.to, attrs, spec); + } + + const decos: Decoration[] = []; + doc.nodesBetween(diff.from, diff.to, (node, pos) => { + if ( + pos >= diff.from && + pos + node.nodeSize <= diff.to && + node.isBlock + ) { + decos.push(Decoration.node(pos, pos + node.nodeSize, attrs, spec)); + return false; + } + return undefined; + }); + if (!decos.length) { + decos.push(Decoration.inline(diff.from, diff.to, attrs, spec)); + } + return decos; + } + + case "inline-delete": { + const inlineFragment = diff.content ?? Fragment.empty; + return Decoration.widget( + diff.from, + () => + renderDeletedFragment(inlineFragment, schema, editor, { + isInline: true, + authorIds, + color, + title: hoverTitle(diff), + }), + { + side: 1, + key: `diff-del-${index}-${inlineFragment.size}`, + diff, + }, + ); + } + + case "block-delete": { + const fragment = diff.content ?? Fragment.empty; + return Decoration.widget( + diff.from, + () => + renderDeletedFragment(fragment, schema, editor, { + isInline: false, + authorIds, + color, + title: hoverTitle(diff), + }), + { + side: 1, + key: `diff-del-${index}-${fragment.size}`, + diff, + }, + ); + } + + default: + return null; + } + }; + +export const YSuggestionPlugin = createExtension(({ editor }) => { + return { + key: "ySuggestion", + prosemirrorPlugins: [ + ySuggestionDecorationPlugin({ + mapDiffToDecorations: defaultMapDiffToDecorations(editor), + }), + ], + runsBefore: ["default"], + } as const; +}); diff --git a/packages/core/src/y/extensions/YSync.ts b/packages/core/src/y/extensions/YSync.ts index f4c9f73574..cca5919586 100644 --- a/packages/core/src/y/extensions/YSync.ts +++ b/packages/core/src/y/extensions/YSync.ts @@ -5,94 +5,6 @@ import { } from "../../editor/BlockNoteExtension.js"; import { CollaborationOptions } from "./index.js"; -/** - * Deterministic hash of a string to an unsigned 32-bit integer. - */ -const hashStr = (s: string): number => { - let h = 0; - for (let i = 0; i < s.length; i++) { - h = ((h << 5) - h + s.charCodeAt(i)) | 0; - } - return h >>> 0; -}; - -/** - * Pick a deterministic user-color from a palette based on user ids. - * Must be deterministic so the sync plugin's readback matches the mapper output. - */ -const userColorPalette = [ - "#30bced", - "#6eeb83", - "#ffbc42", - "#ecd444", - "#ee6352", - "#9ac2c9", - "#8acb88", - "#1be7ff", -]; - -const colorForUserIds = ( - userIds: readonly string[] | undefined | null, -): string => { - if (!userIds || userIds.length === 0) { - return userColorPalette[0]; - } - return userColorPalette[ - hashStr(String(userIds[0])) % userColorPalette.length - ]; -}; - -/** - * Map a Y attribution to BlockNote's `y-attributed-*` mark attrs. - * - * The mapper must be deterministic in `(format, attribution)` and emit - * attrs that exactly match the declared mark schema in SuggestionMarks.ts. - * Any mismatch causes the sync plugin to fire phantom reconcile dispatches - * in a loop. See ATTRIBUTION.md in @y/prosemirror. - * - * Declared attrs per mark (all three are the same shape): - * - y-attributed-insert: { id, "user-color" } - * - y-attributed-delete: { id, "user-color" } - * - y-attributed-format: { id, "user-color" } - */ -const mapAttributionToMark = ( - format: Record | null, - attribution: { - insert?: readonly string[]; - delete?: readonly string[]; - format?: Record; - insertAt?: number; - deleteAt?: number; - formatAt?: number; - }, -): Record => { - const out: Record = { ...format }; - - if (attribution.insert) { - out["y-attributed-insert"] = { - id: attribution.insert[0] ?? null, - "user-color": colorForUserIds(attribution.insert), - }; - } - - if (attribution.delete) { - out["y-attributed-delete"] = { - id: attribution.delete[0] ?? null, - "user-color": colorForUserIds(attribution.delete), - }; - } - - if (attribution.format) { - const userIds = [...new Set(Object.values(attribution.format).flat())]; - out["y-attributed-format"] = { - id: userIds[0] ?? null, - "user-color": colorForUserIds(userIds), - }; - } - - return out; -}; - export const YSyncExtension = createExtension( ({ options, @@ -117,7 +29,6 @@ export const YSyncExtension = createExtension( prosemirrorPlugins: [ syncPlugin({ suggestionDoc: options.suggestionDoc, - mapAttributionToMark, }), ], runsBefore: ["default"], diff --git a/packages/core/src/y/extensions/index.ts b/packages/core/src/y/extensions/index.ts index fe137197db..047e787a21 100644 --- a/packages/core/src/y/extensions/index.ts +++ b/packages/core/src/y/extensions/index.ts @@ -7,6 +7,7 @@ import { import { RelativePositionMappingExtension } from "./RelativePositionMapping.js"; import { CollaborationUser, YCursorExtension } from "./YCursorPlugin.js"; import { YSyncExtension } from "./YSync.js"; +import { YSuggestionPlugin } from "./YSuggestions.js"; import { BlockNoteEditorOptions } from "../../editor/BlockNoteEditor.js"; import { SuggestionsExtension } from "./Suggestions.js"; import { createYjsVersioningAdapter } from "./Versioning.js"; @@ -66,6 +67,7 @@ export const CollaborationExtension = createExtension( RelativePositionMappingExtension(), YSyncExtension(options), YCursorExtension(options), + YSuggestionPlugin(), options.versioningEndpoints ? VersioningExtension({ ...createYjsVersioningAdapter(editor, options.fragment), diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 1ab1b43da8..a7f650a092 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -276,8 +276,7 @@ export function createReactBlockSpec< // `ReactNodeViewRenderer` instead. const block = getBlockFromPos( props.getPos, - editor, - props.editor, + props.view.state.doc, blockConfig.type, ); diff --git a/patches/@y__prosemirror@2.0.0-2.patch b/patches/@y__prosemirror@2.0.0-2.patch index dab913697b..64e9ca2c0f 100644 --- a/patches/@y__prosemirror@2.0.0-2.patch +++ b/patches/@y__prosemirror@2.0.0-2.patch @@ -1,16 +1,10 @@ diff --git a/dist/src/commands.d.ts b/dist/src/commands.d.ts new file mode 100644 -index 0000000000000000000000000000000000000000..a12f7150273c27fef6621b685a608c0c13f0eefa +index 0000000000000000000000000000000000000000..52db229917ea97da2d45900b873ba416d5c58c41 --- /dev/null +++ b/dist/src/commands.d.ts -@@ -0,0 +1,27 @@ -+/** -+ * Switch to pause mode (stop synchronization between prosemirror and ytype) -+ * @param {import('prosemirror-state').EditorState} state -+ * @param {CommandDispatch?} dispatch -+ * @returns {boolean} -+ */ -+export function pauseSync(state: import("prosemirror-state").EditorState, dispatch: CommandDispatch | null): boolean; +@@ -0,0 +1,21 @@ ++export function pauseSync(state: import("prosemirror-state").EditorState, dispatch?: (tr: import("prosemirror-state").Transaction) => void, view?: import("prosemirror-view").EditorView): boolean; +export function configureYProsemirror(opts?: { + ytype?: Y.Type | null | undefined; + attributionManager?: Y.AbstractAttributionManager | null | undefined; @@ -34,11 +28,11 @@ index 0000000000000000000000000000000000000000..a12f7150273c27fef6621b685a608c0c \ No newline at end of file diff --git a/dist/src/commands.d.ts.map b/dist/src/commands.d.ts.map new file mode 100644 -index 0000000000000000000000000000000000000000..817e319bd77f9d07a25146614a47636171902b1f +index 0000000000000000000000000000000000000000..c20bcd20ca4fc83c03acbb1a846fee773532158f --- /dev/null +++ b/dist/src/commands.d.ts.map @@ -0,0 +1 @@ -+{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/commands.js"],"names":[],"mappings":"AAMA;;;;;GAKG;AACH,iCAJW,OAAO,mBAAmB,EAAE,WAAW,YACvC,eAAe,OAAC,GACd,OAAO,CAanB;AAeM,6CAJJ;IAAsB,KAAK;IACQ,kBAAkB;CACrD,GAAU,OAAO,mBAAmB,EAAE,OAAO,CA8B/C;AAQM,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAQjF,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAExF;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAElJ;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAQ3I,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAQM,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;mBA/JkB,MAAM"} ++{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/commands.js"],"names":[],"mappings":";AAkCO,6CAJJ;IAA6B,KAAK;IACiB,kBAAkB;CACrE,GAAU,OAAO,mBAAmB,EAAE,OAAO,CAyB/C;AAQM,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAQjF,4BAHI,OAAO,mBAAmB,EAAE,WAAW,GACtC,OAAO,CAEqE;AAExF;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAElJ;;GAEG;AACH,0BAFU,OAAO,mBAAmB,EAAE,OAAO,CAEqG;AAQ3I,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAQM,qCAJI,MAAM,QACN,MAAM,GACJ,OAAO,mBAAmB,EAAE,OAAO,CAc/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;AAMM,oCAFM,OAAO,mBAAmB,EAAE,OAAO,CAW/C;mBAtJkB,MAAM"} \ No newline at end of file diff --git a/dist/src/cursor-plugin.d.ts b/dist/src/cursor-plugin.d.ts new file mode 100644 @@ -99,11 +93,73 @@ index 0000000000000000000000000000000000000000..f09b4e94cfb42585d13b700cef3f4fb0 @@ -0,0 +1 @@ +{"version":3,"file":"cursor-plugin.d.ts","sourceRoot":"","sources":["../../src/cursor-plugin.js"],"names":[],"mappings":"AAgCO,2CAHI,IAAI,GACH,WAAW,CAmBtB;AAQM,8CAHI,IAAI,GACH,OAAO,kBAAkB,EAAE,eAAe,CAOrD;AAYM,yCATI,OAAO,mBAAmB,EAAE,WAAW,aACvC,OAAO,wBAAwB,EAAE,SAAS,mBAC1C,eAAe,gBACf,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,mBACzC,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,kBAAkB,EAAE,eAAe,oBAC5E,MAAM,UACN;IAAC,KAAK,EAAE,CAAC,CAAC,IAAI,GAAG,IAAI,CAAC;IAAC,kBAAkB,EAAE,CAAC,CAAC,0BAA0B,GAAG,IAAI,CAAA;CAAC,GAAG,SAAS,GAC1F,aAAa,CAkExB;AA2BM,yCATI,OAAO,wBAAwB,EAAE,SAAS,yGAElD;IAA+B,oBAAoB;IACU,aAAa,WAA3D,IAAI,YAAY,MAAM,KAAK,WAAW;IACuC,gBAAgB,WAA7F,IAAI,YAAY,MAAM,KAAK,OAAO,kBAAkB,EAAE,eAAe;IACrC,uBAAuB;IAChD,gBAAgB;CACtC,GAAS,MAAM,CAAC,aAAa,CAAC,CAmL7B;;;;;;;;;;;gDAlUO,MAAM,gBACN,MAAM,kBACN,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KACjB,OAAO;oDAwHjB;IAAmD,IAAI,EAA/C,OAAO,kBAAkB,EAAE,UAAU;IAC8B,SAAS,EAA5E;QAAC,MAAM,EAAE,CAAC,CAAC,gBAAgB,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,gBAAgB,CAAA;KAAC,GAAG,IAAI;IACM,SAAS,EAA5E;QAAC,MAAM,EAAE,CAAC,CAAC,gBAAgB,CAAC;QAAC,IAAI,EAAE,CAAC,CAAC,gBAAgB,CAAA;KAAC,GAAG,IAAI;IAChD,UAAU,EAAvB,OAAO;IAC0B,MAAM,EAAvC,QAAQ,GAAG,OAAO,GAAG,MAAM;CACnC,KAAU;IAAC,MAAM,EAAE,CAAC,CAAC,gBAAgB,CAAC;IAAC,IAAI,EAAE,CAAC,CAAC,gBAAgB,CAAA;CAAC,GAAG,IAAI;mBApJvD,MAAM;8BACiB,kBAAkB;uBACrC,mBAAmB"} \ No newline at end of file +diff --git a/dist/src/diff-decorations.d.ts b/dist/src/diff-decorations.d.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..66c8346cd99337a1087c50dec735ed0f1f897e3e +--- /dev/null ++++ b/dist/src/diff-decorations.d.ts +@@ -0,0 +1,47 @@ ++export function renderDeletedContent(fragment: import("prosemirror-model").Fragment, schema: import("prosemirror-model").Schema, opts?: { ++ authorIds?: string[]; ++ color?: string; ++ title?: string; ++}): HTMLElement; ++/** ++ * Default mapping from a single `Diff` to decoration(s). Returns a `Decoration`, ++ * an array of them, or `null` to skip. ++ * ++ * @type {MapDiffToDecorations} ++ */ ++export const defaultMapDiffToDecorations: MapDiffToDecorations; ++export function buildDiffDecorationSet(doc: import("prosemirror-model").Node, diffs: DiffSet, schema: import("prosemirror-model").Schema, opts?: SuggestionDecorationOptions): DecorationSet; ++export function suggestionDiffPlugin({ diffs, mapDiffToDecorations, colorForAuthors }?: SuggestionDecorationOptions & { ++ diffs?: DiffSet; ++}): Plugin; ++export type Diff = import("./y-attribution-to-diffset.js").Diff; ++export type DiffSet = import("./y-attribution-to-diffset.js").DiffSet; ++export type DiffType = import("./y-attribution-to-diffset.js").DiffType; ++export type Attribution = import("./y-attribution-to-diffset.js").Attribution; ++/** ++ * Arguments passed to a `mapDiffToDecorations` callback. ++ */ ++export type MapDiffArgs = { ++ diff: Diff; ++ doc: import("prosemirror-model").Node; ++ schema: import("prosemirror-model").Schema; ++ index: number; ++ color?: string; ++ attributes?: import("prosemirror-view").DecorationAttrs; ++ defaultMapDiffToDecorations?: MapDiffToDecorations; ++}; ++/** ++ * Callback that converts a single `Diff` to decoration(s). ++ * Return a `Decoration`, an array of them, or `null` to skip. ++ */ ++export type MapDiffToDecorations = (args: MapDiffArgs) => import("prosemirror-view").Decoration | import("prosemirror-view").Decoration[] | null; ++/** ++ * Options shared by `buildDiffDecorationSet` and `ySuggestionDecorationPlugin`. ++ */ ++export type SuggestionDecorationOptions = { ++ colorForAuthors?: (authorIds: string[]) => (string | undefined); ++ mapDiffToDecorations?: MapDiffToDecorations; ++}; ++import { DecorationSet } from 'prosemirror-view'; ++import { Plugin } from 'prosemirror-state'; ++//# sourceMappingURL=diff-decorations.d.ts.map +\ No newline at end of file +diff --git a/dist/src/diff-decorations.d.ts.map b/dist/src/diff-decorations.d.ts.map +new file mode 100644 +index 0000000000000000000000000000000000000000..fda53e2c6b828544e6abf535b3851a0e755d8d81 +--- /dev/null ++++ b/dist/src/diff-decorations.d.ts.map +@@ -0,0 +1 @@ ++{"version":3,"file":"diff-decorations.d.ts","sourceRoot":"","sources":["../../src/diff-decorations.js"],"names":[],"mappings":"AAkEO,+CALI,OAAO,mBAAmB,EAAE,QAAQ,UACpC,OAAO,mBAAmB,EAAE,MAAM,SAClC;IAAE,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtD,WAAW,CAsBvB;AAoED;;;;;GAKG;AACH,0CAFU,oBAAoB,CA+C7B;AAWM,4CANI,OAAO,mBAAmB,EAAE,IAAI,SAChC,OAAO,UACP,OAAO,mBAAmB,EAAE,MAAM,SAClC,2BAA2B,GACzB,aAAa,CAazB;AAWM,wFAHI,2BAA2B,GAAG;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAC/C,MAAM,CAAC,aAAa,CAAC,CAmB9B;mBA5OS,OAAO,+BAA+B,EAAE,IAAI;sBAC5C,OAAO,+BAA+B,EAAE,OAAO;uBAC/C,OAAO,+BAA+B,EAAE,QAAQ;0BAChD,OAAO,+BAA+B,EAAE,WAAW;;;;0BAMnD;IACR,IAAI,EAAE,IAAI,CAAC;IACX,GAAG,EAAE,OAAO,mBAAmB,EAAE,IAAI,CAAC;IACtC,MAAM,EAAE,OAAO,mBAAmB,EAAE,MAAM,CAAC;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,OAAO,kBAAkB,EAAE,eAAe,CAAC;IACxD,2BAA2B,CAAC,EAAE,oBAAoB,CAAA;CACnD;;;;;0CAQO,WAAW,KACT,OAAO,kBAAkB,EAAE,UAAU,GAAG,OAAO,kBAAkB,EAAE,UAAU,EAAE,GAAG,IAAI;;;;0CAMtF;IACR,eAAe,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAChE,oBAAoB,CAAC,EAAE,oBAAoB,CAAA;CAC5C;8BAzCsC,kBAAkB;uBACrC,mBAAmB"} +\ No newline at end of file diff --git a/dist/src/index.d.ts b/dist/src/index.d.ts -index fec5f1c23d3f28e250fecd7045fcebe7fc60993f..ebf62e224dcb8a4becb6dcc0e59799e732a4ce1c 100644 +index fec5f1c23d3f28e250fecd7045fcebe7fc60993f..6191cc561c6e913f46921419bcc5c88b9f162a96 100644 --- a/dist/src/index.d.ts +++ b/dist/src/index.d.ts -@@ -1,84 +1,8 @@ +@@ -1,84 +1,18 @@ -/** - * @param {Y.XmlFragment} ytype - * @param {object} opts @@ -194,23 +250,34 @@ index fec5f1c23d3f28e250fecd7045fcebe7fc60993f..ebf62e224dcb8a4becb6dcc0e59799e7 +export * from "./commands.js"; +export * from "./undo-plugin.js"; +export * from "./cursor-plugin.js"; -+export { docToDelta, $prosemirrorDelta, defaultMapAttributionToMark } from "./sync-utils.js"; ++export { ydeltaToDiffSet } from "./y-attribution-to-diffset.js"; ++export { ySuggestionDecorationPlugin } from "./suggestion-decoration-plugin.js"; ++export type Attribution = import("./y-attribution-to-diffset.js").Attribution; ++export type Diff = import("./y-attribution-to-diffset.js").Diff; ++export type DiffType = import("./y-attribution-to-diffset.js").DiffType; ++export type DiffSet = import("./y-attribution-to-diffset.js").DiffSet; ++export type SuggestionDecorationOptions = import("./diff-decorations.js").SuggestionDecorationOptions; ++export type MapDiffToDecorations = import("./diff-decorations.js").MapDiffToDecorations; ++export type MapDiffArgs = import("./diff-decorations.js").MapDiffArgs; ++export { docToDelta, $prosemirrorDelta } from "./sync-utils.js"; ++export { buildDiffDecorationSet, suggestionDiffPlugin, renderDeletedContent, defaultMapDiffToDecorations } from "./diff-decorations.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/src/index.d.ts.map b/dist/src/index.d.ts.map new file mode 100644 -index 0000000000000000000000000000000000000000..4b136e26cf4d54488bfbbaf749a89197c074cd91 +index 0000000000000000000000000000000000000000..d131ae17f5d02df2af137cb93c380689b1e5464d --- /dev/null +++ b/dist/src/index.d.ts.map @@ -0,0 +1 @@ -+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.js"],"names":[],"mappings":""} ++{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.js"],"names":[],"mappings":";;;;;;;;0BAQc,OAAO,+BAA+B,EAAE,WAAW;mBACnD,OAAO,+BAA+B,EAAE,IAAI;uBAC5C,OAAO,+BAA+B,EAAE,QAAQ;sBAChD,OAAO,+BAA+B,EAAE,OAAO;0CAE/C,OAAO,uBAAuB,EAAE,2BAA2B;mCAC3D,OAAO,uBAAuB,EAAE,oBAAoB;0BACpD,OAAO,uBAAuB,EAAE,WAAW"} \ No newline at end of file diff --git a/dist/src/keys.d.ts b/dist/src/keys.d.ts new file mode 100644 -index 0000000000000000000000000000000000000000..e60986981f3d3835d7842915790cc6df50f4f1e7 +index 0000000000000000000000000000000000000000..0b8d192e3e3099785c3bda2450b923ee540b4b8d --- /dev/null +++ b/dist/src/keys.d.ts -@@ -0,0 +1,23 @@ +@@ -0,0 +1,39 @@ ++/** @typedef {import('lib0/schema').Unwrap} SyncPluginState */ +/** + * The unique prosemirror plugin key for {@link import('./sync-plugin.js').syncPlugin} + * @@ -232,16 +299,31 @@ index 0000000000000000000000000000000000000000..e60986981f3d3835d7842915790cc6df + * @type {PluginKey} + */ +export const yCursorPluginKey: PluginKey; ++/** ++ * The unique prosemirror plugin key for {@link import('./suggestion-decoration-plugin.js').ySuggestionDecorationPlugin} ++ * ++ * @public ++ * @type {PluginKey} ++ */ ++export const ySuggestionDecorationPluginKey: PluginKey; ++/** ++ * The unique prosemirror plugin key for {@link import('./diff-decorations.js').suggestionDiffPlugin} ++ * ++ * @public ++ * @type {PluginKey} ++ */ ++export const suggestionDiffPluginKey: PluginKey; ++export type SyncPluginState = import("lib0/schema").Unwrap; +import { PluginKey } from 'prosemirror-state'; +//# sourceMappingURL=keys.d.ts.map \ No newline at end of file diff --git a/dist/src/keys.d.ts.map b/dist/src/keys.d.ts.map new file mode 100644 -index 0000000000000000000000000000000000000000..9f12f341c63e7ae2bd51640eefd3df47015b4398 +index 0000000000000000000000000000000000000000..a2f34d3f21d0b22d2d1f07fd1c1d77841fc14665 --- /dev/null +++ b/dist/src/keys.d.ts.map @@ -0,0 +1 @@ -+{"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../../src/keys.js"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,6BAFU,SAAS,CAAC,eAAe,CAAC,CAEiB;AAErD;;;;;GAKG;AACH,6BAFU,SAAS,CAAC,OAAO,kBAAkB,EAAE,eAAe,CAAC,CAEV;AAErD;;;;;GAKG;AACH,+BAFU,SAAS,CAAC,OAAO,kBAAkB,EAAE,aAAa,CAAC,CAEJ;0BAxB/B,mBAAmB"} ++{"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../../src/keys.js"],"names":[],"mappings":"AAEA,kHAAkH;AAElH;;;;;GAKG;AACH,6BAFU,SAAS,CAAC,eAAe,CAAC,CAEiB;AAErD;;;;;GAKG;AACH,6BAFU,SAAS,CAAC,OAAO,kBAAkB,EAAE,eAAe,CAAC,CAEV;AAErD;;;;;GAKG;AACH,+BAFU,SAAS,CAAC,OAAO,kBAAkB,EAAE,aAAa,CAAC,CAEJ;AAEzD;;;;;GAKG;AACH,6CAFU,SAAS,CAAC,OAAO,kBAAkB,EAAE,aAAa,CAAC,CAE0B;AAEvF;;;;;GAKG;AACH,sCAFU,SAAS,CAAC,OAAO,kBAAkB,EAAE,aAAa,CAAC,CAEU;8BAxCzD,OAAO,aAAa,EAAE,MAAM,CAAC,cAAc,kBAAkB,EAAE,gBAAgB,CAAC;0BAFpE,mBAAmB"} \ No newline at end of file diff --git a/dist/src/lib.d.ts b/dist/src/lib.d.ts deleted file mode 100644 @@ -284,14 +366,35 @@ index 0000000000000000000000000000000000000000..e4f768c579f11b08055a31cc166e8c34 @@ -0,0 +1 @@ +{"version":3,"file":"positions.d.ts","sourceRoot":"","sources":["../../src/positions.js"],"names":[],"mappings":"AAWO,gEALI,OAAO,mBAAmB,EAAE,WAAW,QACvC,CAAC,CAAC,IAAI,OACN,CAAC,CAAC,0BAA0B,GAAG,IAAI,GAClC,CAAC,CAAC,gBAAgB,CA6C7B;AAUM,2DANI,CAAC,CAAC,gBAAgB,gBAClB,CAAC,CAAC,IAAI,SACN,OAAO,mBAAmB,EAAE,IAAI,OAChC,CAAC,CAAC,0BAA0B,GAAG,IAAI,GAClC,IAAI,GAAC,MAAM,CAmDtB;AASM,mDALI,OAAO,mBAAmB,EAAE,WAAW,QACvC,CAAC,CAAC,IAAI,OACN,CAAC,CAAC,0BAA0B,GAC1B,CAAC,GAAG,EAAE,OAAO,mBAAmB,EAAE,IAAI,EAAE,YAAY,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,kBAAkB,CAAC,EAAE,CAAC,CAAC,0BAA0B,KAAK,MAAM,CAWvI;AAyBM,mDAHI,CAAC,CAAC,IAAI,GACJ;IAAC,cAAc,EAAE,cAAc,CAAC;IAAC,cAAc,EAAE,cAAc,CAAA;CAAC,CAyD5E;mCA5EU,OAAO,mBAAmB,EAAE,IAAI,wFAG9B,OAAO,uBAAuB,EAAE,QAAQ;oCAK1C,CAAC,CAAC,IAAI,SACN,OAAO,mBAAmB,EAAE,IAAI,2DAE9B,OAAO,uBAAuB,EAAE,QAAQ;mBAlJlC,MAAM"} \ No newline at end of file +diff --git a/dist/src/suggestion-decoration-plugin.d.ts b/dist/src/suggestion-decoration-plugin.d.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..9c8a6d99d690d36a8d01af6b0542b8605ef269d7 +--- /dev/null ++++ b/dist/src/suggestion-decoration-plugin.d.ts +@@ -0,0 +1,5 @@ ++export function ySuggestionDecorationPlugin(opts?: SuggestionDecorationOptions): Plugin; ++export type SuggestionDecorationOptions = import("./diff-decorations.js").SuggestionDecorationOptions; ++import { Plugin } from 'prosemirror-state'; ++import { DecorationSet } from 'prosemirror-view'; ++//# sourceMappingURL=suggestion-decoration-plugin.d.ts.map +\ No newline at end of file +diff --git a/dist/src/suggestion-decoration-plugin.d.ts.map b/dist/src/suggestion-decoration-plugin.d.ts.map +new file mode 100644 +index 0000000000000000000000000000000000000000..29116f660b11e7587cbea06b6b4cf9719f0fa228 +--- /dev/null ++++ b/dist/src/suggestion-decoration-plugin.d.ts.map +@@ -0,0 +1 @@ ++{"version":3,"file":"suggestion-decoration-plugin.d.ts","sourceRoot":"","sources":["../../src/suggestion-decoration-plugin.js"],"names":[],"mappings":"AAiDO,mDAHI,2BAA2B,GACzB,MAAM,CAAC,aAAa,CAAC,CA2B9B;0CAvDU,OAAO,uBAAuB,EAAE,2BAA2B;uBALlD,mBAAmB;8BACZ,kBAAkB"} +\ No newline at end of file diff --git a/dist/src/sync-plugin.d.ts b/dist/src/sync-plugin.d.ts new file mode 100644 -index 0000000000000000000000000000000000000000..c1da2aa33b86511936e9b1ba4d2d3c848e0c70da +index 0000000000000000000000000000000000000000..229de152a175bfe747471b352465b9545e9bbb26 --- /dev/null +++ b/dist/src/sync-plugin.d.ts -@@ -0,0 +1,41 @@ +@@ -0,0 +1,39 @@ +/** -+ * This Prosemirror {@link Plugin} is responsible for synchronizing the prosemirror {@link EditorState} with a {@link Y.XmlFragment} ++ * This Prosemirror {@link Plugin} is responsible for synchronizing the prosemirror ++ * {@link import('prosemirror-state').EditorState} with a {@link Y.XmlFragment}. + * + * The PM->Y diff/apply pipeline runs in the plugin's `view().update` + * hook (i.e. after the dispatch has been committed to the view), not @@ -299,53 +402,50 @@ index 0000000000000000000000000000000000000000..c1da2aa33b86511936e9b1ba4d2d3c84 + * cause speculative `state.apply` callers to write to Y as a side + * effect. + * -+ * @param {object} opts -+ * @param {Y.Doc} [opts.suggestionDoc] A {@link Y.Doc} to use for suggestion tracking -+ * @param {AttributionMapper} [opts.mapAttributionToMark] A function to map the {@link Y.Attribution} to a {@link import('prosemirror-model').Mark} - the mark names *must* be one of: `y-attributed-insert`, `y-attributed-delete`, `y-attributed-format`. No other mark names are permitted -+ * @param {AttributedNodesPredicate} [opts.attributedNodes] Optional predicate `(nodeName, kinds) => boolean`. When it returns `true` for an attributed node *and* a `{nodeName}--attributed` type exists in the schema, that node is rendered under the variant type (the `y-attributed-*` marks are still applied). `kinds` is `{ insert?, delete?, format? }`. The variant is a pure rendering concern - the canonical name is what is stored in the Y document. The predicate must be deterministic in `(nodeName, kinds)`. ++ * The PM document always mirrors the **clean** Y content (no attribution ++ * marks, no deleted text). The write path applies diffs through the AM ++ * so edits are tagged as suggestions. Attribution rendering is handled ++ * by the separate {@link import('./suggestion-decoration-plugin.js').ySuggestionDecorationPlugin}. ++ * ++ * @param {object} [opts] + * @returns {Plugin} + */ -+export function syncPlugin(opts?: { -+ suggestionDoc?: Y.Doc | undefined; -+ mapAttributionToMark?: AttributionMapper | undefined; -+ attributedNodes?: AttributedNodesPredicate | undefined; -+}): Plugin; ++export function syncPlugin(opts?: object): Plugin; +/** -+ * The y-prosemirror binding is a bi-directional synchronization with the provided Y.Type and the EditorView ++ * The y-prosemirror binding is a bi-directional synchronization with the provided Y.Type and the EditorView. + * Any change applied to the EditorView will be applied (via deltas) to the Y.Type, and vice versa. ++ * ++ * The PM document always contains **clean** content — no attribution marks, ++ * no deleted text inline. Attribution is rendered as decorations by ++ * {@link import('./suggestion-decoration-plugin.js').ySuggestionDecorationPlugin}. + */ +export const $syncPluginState: s.Schema<{ + ytype: Y.Type | null; + attributionManager: Y.AbstractAttributionManager | null; -+ attributionMapper: AttributionMapper; -+ attributedNodes: AttributedNodesPredicate; +}>; +export const $syncPluginStateUpdate: s.Schema<{ + ytype?: Y.Type | null | undefined; + attributionManager?: Y.AbstractAttributionManager | null | undefined; -+ attributionMapper?: AttributionMapper | null | undefined; -+ attributedNodes?: AttributedNodesPredicate | null | undefined; -+ change?: Y.YEvent | null | undefined; +}>; -+import * as Y from '@y/y'; +import { Plugin } from 'prosemirror-state'; ++import * as Y from '@y/y'; +import * as s from 'lib0/schema'; +//# sourceMappingURL=sync-plugin.d.ts.map \ No newline at end of file diff --git a/dist/src/sync-plugin.d.ts.map b/dist/src/sync-plugin.d.ts.map new file mode 100644 -index 0000000000000000000000000000000000000000..df8c9df944fe1c64c46c648d913a0f8b52694bd7 +index 0000000000000000000000000000000000000000..257913a7524665058045da2e87779eaf0563fc18 --- /dev/null +++ b/dist/src/sync-plugin.d.ts.map @@ -0,0 +1 @@ -+{"version":3,"file":"sync-plugin.d.ts","sourceRoot":"","sources":["../../src/sync-plugin.js"],"names":[],"mappings":"AAgGA;;;;;;;;;;;;;;GAcG;AACH,kCALG;IAAqB,aAAa;IACD,oBAAoB;IACb,eAAe;CACvD,GAAU,MAAM,CA+LlB;AA7RD;;;GAGG;AACH;;;;;GAYE;AAEF;;;;;;GAME;mBAvCiB,MAAM;uBACF,mBAAmB;mBAWvB,aAAa"} ++{"version":3,"file":"sync-plugin.d.ts","sourceRoot":"","sources":["../../src/sync-plugin.js"],"names":[],"mappings":"AAgCA;;;;;;;;;;;;;;;;;GAiBG;AACH,kCAHW,MAAM,GACJ,MAAM,CAkKlB;AArMD;;;;;;;GAOG;AACH;;;GAGE;AAEF;;;GAGE;uBA5BqB,mBAAmB;mBADvB,MAAM;mBAUN,aAAa"} \ No newline at end of file diff --git a/dist/src/sync-utils.d.ts b/dist/src/sync-utils.d.ts new file mode 100644 -index 0000000000000000000000000000000000000000..dfb00a847adcc5a1db01d557a8b0b056eefd1c9a +index 0000000000000000000000000000000000000000..d57c7fe0a258d821eb58d615bd93fa3a1deffcf1 --- /dev/null +++ b/dist/src/sync-utils.d.ts -@@ -0,0 +1,146 @@ +@@ -0,0 +1,105 @@ +/** + * Transforms a {@link Node} into a {@link Y.XmlFragment} + * @param {Node} node @@ -358,28 +458,6 @@ index 0000000000000000000000000000000000000000..dfb00a847adcc5a1db01d557a8b0b056 + attributionManager?: Y.AbstractAttributionManager | undefined; +}): Y.Type; +/** -+ * Applies a {@link Y.XmlFragment}'s content as a ProseMirror {@link Transaction} -+ * @param {Y.Type} fragment -+ * @param {import('prosemirror-state').Transaction} tr -+ * @param {object} ctx -+ * @param {Y.AbstractAttributionManager} [ctx.attributionManager] -+ * @param {typeof defaultMapAttributionToMark} [ctx.mapAttributionToMark] -+ * @param {AttributedNodesPredicate} [ctx.attributedNodes] -+ * @returns {import('prosemirror-state').Transaction} -+ */ -+export function fragmentToTr(fragment: Y.Type, tr: import("prosemirror-state").Transaction, { attributionManager, mapAttributionToMark, attributedNodes }?: { -+ attributionManager?: Y.AbstractAttributionManager | undefined; -+ mapAttributionToMark?: ((format: Record | null, attribution: T) => Record | null) | undefined; -+ attributedNodes?: AttributedNodesPredicate | undefined; -+}): import("prosemirror-state").Transaction; -+/** -+ * Transforms a {@link Y.XmlFragment} into a {@link Node} -+ * @param {Y.Type} fragment -+ * @param {import('prosemirror-state').Transaction} tr -+ * @return {Node} -+ */ -+export function fragmentToPm(fragment: Y.Type, tr: import("prosemirror-state").Transaction): Node; -+/** + * This function is used to find the delta offset for a given prosemirror offset in a node. + * Given the following document: + *

Hello world

Hello world!

@@ -413,31 +491,11 @@ index 0000000000000000000000000000000000000000..dfb00a847adcc5a1db01d557a8b0b056 + text: true; + recursiveChildren: true; +}>>; -+/** -+ * Suffix appended to a node name when it is rendered as its "attributed -+ * variant" (see `attributedNodes` on {@link syncPlugin}). The suffix is fixed -+ * so that canonicalizing back (PM -> Y) is a pure string operation and can -+ * never drift from the forward mapping. `--attributed` is a *reserved* suffix: -+ * a real node type literally ending in it would be canonicalized away on the -+ * way to Y. -+ */ -+export const ATTRIBUTED_SUFFIX: "--attributed"; -+/** -+ * Default `attributedNodes` predicate - the feature is off, so every node keeps -+ * its canonical name. -+ * -+ * @type {AttributedNodesPredicate} -+ */ -+export const defaultAttributedNodes: AttributedNodesPredicate; -+export function canonicalNodeName(name: string): string; -+export function attributedVariant(canonicalName: string, format: Record | null | undefined, attributedNodes: AttributedNodesPredicate, schema: import("prosemirror-model").Schema): string; -+export function defaultMapAttributionToMark(format: Record | null, attribution: T): Record | null; -+export function deltaAttributionToFormat(d: delta.DeltaAny, attributionsToFormat: Function): ProsemirrorDelta; +export function formattingAttributesToMarks(formatting: { + [key: string]: any; +} | null, schema: import("prosemirror-model").Schema): import("prosemirror-model").Mark[]; +export function nodesToDelta(ns: Array): ProsemirrorDelta; -+export function nodeToDelta(n: Node, nodeName?: string | null, canonicalize?: boolean): ProsemirrorDelta; ++export function nodeToDelta(n: Node, nodeName?: string | null): ProsemirrorDelta; +export function docToDelta(doc: Node): delta.Delta<{ + name: string; + attrs: { @@ -448,8 +506,8 @@ index 0000000000000000000000000000000000000000..dfb00a847adcc5a1db01d557a8b0b056 +}>; +export function deltaToPSteps(tr: import("prosemirror-state").Transaction, d: ProsemirrorDelta, pnode?: Node, currPos?: { + i: number; -+}, attributedNodes?: AttributedNodesPredicate): import("prosemirror-state").Transaction; -+export function deltaToPNode(d: ProsemirrorDelta, schema: import("prosemirror-model").Schema, dformat: delta.FormattingAttributes | null, attributedNodes?: AttributedNodesPredicate): Node; ++}): import("prosemirror-state").Transaction; ++export function deltaToPNode(d: ProsemirrorDelta, schema: import("prosemirror-model").Schema, dformat: delta.FormattingAttributes | null): Node; +export function docDiffToDelta(beforeDoc: Node, afterDoc: Node): delta.Delta<{ + name: string; + attrs: { @@ -458,7 +516,7 @@ index 0000000000000000000000000000000000000000..dfb00a847adcc5a1db01d557a8b0b056 + text: true; + recursiveChildren: true; +}>; -+export function trToDelta(tr: Transaction): delta.Delta<{ ++export function trToDelta(tr: import("prosemirror-state").Transaction): delta.Delta<{ + name: string; + attrs: { + [x: string]: any; @@ -468,6 +526,7 @@ index 0000000000000000000000000000000000000000..dfb00a847adcc5a1db01d557a8b0b056 +}>; +export function stepToDelta(step: import("prosemirror-transform").Step, beforeDoc: import("prosemirror-model").Node): ProsemirrorDelta; +export function deltaModifyNodeAt(node: Node, pmOffset: number, mod: (d: delta.DeltaBuilderAny) => any): ProsemirrorDelta; ++export type ProsemirrorDelta = import("lib0/schema").Unwrap; +/** + * A single child op of a {@link ProsemirrorDelta} (retain / modify / insert / + * text / delete). @@ -495,11 +554,11 @@ index 0000000000000000000000000000000000000000..dfb00a847adcc5a1db01d557a8b0b056 \ No newline at end of file diff --git a/dist/src/sync-utils.d.ts.map b/dist/src/sync-utils.d.ts.map new file mode 100644 -index 0000000000000000000000000000000000000000..8d7883745029eee21f25288286021206007fd3ff +index 0000000000000000000000000000000000000000..b0b2547bc3ccb9951702eb80e8ea52b43b2f2a93 --- /dev/null +++ b/dist/src/sync-utils.d.ts.map @@ -0,0 +1 @@ -+{"version":3,"file":"sync-utils.d.ts","sourceRoot":"","sources":["../../src/sync-utils.js"],"names":[],"mappings":"AAsNA;;;;;;;GAOG;AACH,mCANW,IAAI,YACJ,CAAC,CAAC,IAAI,2BAEd;IAA4C,kBAAkB;CAC9D,GAAU,CAAC,CAAC,IAAI,CASlB;AAED;;;;;;;;;GASG;AACH,uCARW,CAAC,CAAC,IAAI,MACN,OAAO,mBAAmB,EAAE,WAAW,kEAE/C;IAA2C,kBAAkB;IACZ,oBAAoB,KAxIxB,CAAC,SAApC,OAAQ,YAAY,EAAE,WAAY,UACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,eAC9B,CAAC,KACC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAsID,eAAe;CACtD,GAAU,OAAO,mBAAmB,EAAE,WAAW,CAiBnD;AAED;;;;;GAKG;AACH,uCAJW,CAAC,CAAC,IAAI,MACN,OAAO,mBAAmB,EAAE,WAAW,GACtC,IAAI,CAIf;AAoYD;;;;;;;;;;;;;;;;;GAiBG;AACH,oCAJW,IAAI,mBACJ,MAAM,GACL,MAAM,EAAE,CAwBnB;AAED;;;;;GAKG;AACH,yCAJW,MAAM,EAAE,QACR,IAAI,GACH,MAAM,CAgCjB;AAzsBD;;;;;;;IAA4I;AAE5I;;;;;;;GAOG;AACH,gCAAiC,cAAc,CAAA;AAE/C;;;;;GAKG;AACH,qCAFU,wBAAwB,CAEe;AAS1C,wCAHI,MAAM,GACL,MAAM,CAKR;AAcH,iDANI,MAAM,UACN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,mBAC1C,wBAAwB,UACxB,OAAO,mBAAmB,EAAE,MAAM,GACjC,MAAM,CAajB;AAgCM,4CALyC,CAAC,SAApC,OAAQ,YAAY,EAAE,WAAY,UACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,eAC9B,CAAC,GACC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAiC1C;AAOM,4CAHI,KAAK,CAAC,QAAQ,mCA4BL,gBAAgB,CACnC;AA0BM,wDAHI;IAAC,CAAC,GAAG,EAAC,MAAM,GAAE,GAAG,CAAA;CAAC,GAAC,IAAI,UACvB,OAAO,mBAAmB,EAAE,MAAM,sCAGwD;AAM9F,iCAHI,KAAK,CAAC,IAAI,CAAC,GACV,gBAAgB,CAW3B;AAgEM,+BAPI,IAAI,aACJ,MAAM,OAAC,iBACP,OAAO,GAGN,gBAAgB,CAoB3B;AAKM,gCAFI,IAAI;;;;;;;GAEwC;AAmEhD,kCAPI,OAAO,mBAAmB,EAAE,WAAW,KACvC,gBAAgB,UAChB,IAAI,YACJ;IAAE,CAAC,EAAE,MAAM,CAAA;CAAE,oBACb,wBAAwB,GACvB,OAAO,mBAAmB,EAAE,WAAW,CAuJlD;AASM,gCANI,gBAAgB,UAChB,OAAO,mBAAmB,EAAE,MAAM,WAClC,KAAK,CAAC,oBAAoB,GAAC,IAAI,oBAC/B,wBAAwB,GACvB,IAAI,CAkCf;AAMM,0CAHI,IAAI,YACJ,IAAI;;;;;;;GAMd;AAKM,8BAFI,WAAW;;;;;;;GAkBrB;AA+CM,kCAJI,OAAO,uBAAuB,EAAE,IAAI,aACpC,OAAO,mBAAmB,EAAE,IAAI,GAC/B,gBAAgB,CAQ3B;AAoGM,wCALI,IAAI,YACJ,MAAM,OACN,CAAC,CAAC,EAAC,KAAK,CAAC,eAAe,KAAG,GAAG,GAC7B,gBAAgB,CAa3B;;;;;iCArZY,KAAK,CAAC,aAAa;;;;;;;;;aAQlB,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAC,KAAK,CAAC,MAAM,CAAC;;;;aACvC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC;;qBA5VG,mBAAmB;mBAPtC,MAAM;uBAEF,YAAY;mBAIhB,aAAa"} ++{"version":3,"file":"sync-utils.d.ts","sourceRoot":"","sources":["../../src/sync-utils.js"],"names":[],"mappings":"AA+DA;;;;;;;GAOG;AACH,mCANW,IAAI,YACJ,CAAC,CAAC,IAAI,2BAEd;IAA4C,kBAAkB;CAC9D,GAAU,CAAC,CAAC,IAAI,CAOlB;AA6YD;;;;;;;;;;;;;;;;;GAiBG;AACH,oCAJW,IAAI,mBACJ,MAAM,GACL,MAAM,EAAE,CA2BnB;AAED;;;;;GAKG;AACH,yCAJW,MAAM,EAAE,QACR,IAAI,GACH,MAAM,CAsCjB;AA/hBD;;;;;;;IAA4I;AA4BrI,wDAHI;IAAC,CAAC,GAAG,EAAC,MAAM,GAAE,GAAG,CAAA;CAAC,GAAC,IAAI,UACvB,OAAO,mBAAmB,EAAE,MAAM,sCAGwD;AAM9F,iCAHI,KAAK,CAAC,IAAI,CAAC,GACV,gBAAgB,CAW3B;AAsBM,+BAJI,IAAI,aACJ,MAAM,OAAC,GACN,gBAAgB,CAS3B;AAKM,gCAFI,IAAI;;;;;;;GAEwC;AA2ChD,kCANI,OAAO,mBAAmB,EAAE,WAAW,KACvC,gBAAgB,UAChB,IAAI,YACJ;IAAE,CAAC,EAAE,MAAM,CAAA;CAAE,GACZ,OAAO,mBAAmB,EAAE,WAAW,CA0JlD;AAQM,gCALI,gBAAgB,UAChB,OAAO,mBAAmB,EAAE,MAAM,WAClC,KAAK,CAAC,oBAAoB,GAAC,IAAI,GAC9B,IAAI,CA6Bf;AAMM,0CAHI,IAAI,YACJ,IAAI;;;;;;;GAMd;AAKM,8BAFI,OAAO,mBAAmB,EAAE,WAAW;;;;;;;GAkBjD;AAiGM,kCAJI,OAAO,uBAAuB,EAAE,IAAI,aACpC,OAAO,mBAAmB,EAAE,IAAI,GAC/B,gBAAgB,CAQ3B;AA6GM,wCALI,IAAI,YACJ,MAAM,OACN,CAAC,CAAC,EAAC,KAAK,CAAC,eAAe,KAAG,GAAG,GAC7B,gBAAgB,CAa3B;+BAhjBa,OAAO,aAAa,EAAE,MAAM,CAAC,OAAO,iBAAiB,CAAC;;;;;iCAoGvD,KAAK,CAAC,aAAa;;;;;;;;;aAQlB,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAC,KAAK,CAAC,MAAM,CAAC;;;;aACvC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC;;qBA3Hc,mBAAmB;mBALjD,MAAM;uBACF,YAAY;mBAGhB,aAAa"} \ No newline at end of file diff --git a/dist/src/undo-plugin.d.ts b/dist/src/undo-plugin.d.ts new file mode 100644 @@ -533,6 +592,61 @@ index 0000000000000000000000000000000000000000..665bb84203a88b35e2961e7221a31896 diff --git a/dist/src/utils.d.ts b/dist/src/utils.d.ts deleted file mode 100644 index 9006a87dd42992dfe0aa0f7ab5298983deb3357a..0000000000000000000000000000000000000000 +diff --git a/dist/src/y-attribution-to-diffset.d.ts b/dist/src/y-attribution-to-diffset.d.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..d9a219ef72198224a123e58b31cbd509cb2cfbf5 +--- /dev/null ++++ b/dist/src/y-attribution-to-diffset.d.ts +@@ -0,0 +1,40 @@ ++export function ydeltaToDiffSet(attributedDelta: d.DeltaAny, { displayedDoc, schema }: { ++ displayedDoc: import("prosemirror-model").Node; ++ schema: import("prosemirror-model").Schema; ++}): DiffSet; ++/** ++ * Who made a change and whether it was an insertion or deletion. ++ */ ++export type Attribution = { ++ type: "added" | "removed"; ++ authorIds: string[]; ++ timestamp?: number; ++}; ++/** ++ * The six diff types produced by `ydeltaToDiffSet`. ++ */ ++export type DiffType = "inline-insert" | "inline-delete" | "block-insert" | "block-delete" | "inline-update" | "block-update"; ++/** ++ * A single difference between the base and suggestion documents. ++ * ++ * `from`/`to` are positions in the *displayed* (clean) PM document. ++ * For delete types, `from === to` (zero-width) and `content` holds the ++ * removed fragment for ghost rendering. For update types, `attributes` ++ * holds the new values and `previousAttributes` (when available) the old. ++ */ ++export type Diff = { ++ type: DiffType; ++ from: number; ++ to: number; ++ content?: import("prosemirror-model").Fragment; ++ attribution: Attribution; ++ attributes?: Record; ++ previousAttributes?: Record; ++}; ++/** ++ * An ordered array of `Diff` objects covering all changes between the ++ * base and suggestion documents. ++ */ ++export type DiffSet = Diff[]; ++import * as d from 'lib0/delta'; ++//# sourceMappingURL=y-attribution-to-diffset.d.ts.map +\ No newline at end of file +diff --git a/dist/src/y-attribution-to-diffset.d.ts.map b/dist/src/y-attribution-to-diffset.d.ts.map +new file mode 100644 +index 0000000000000000000000000000000000000000..229fa9e700846567721d8dedaa5721ac7b29eae8 +--- /dev/null ++++ b/dist/src/y-attribution-to-diffset.d.ts.map +@@ -0,0 +1 @@ ++{"version":3,"file":"y-attribution-to-diffset.d.ts","sourceRoot":"","sources":["../../src/y-attribution-to-diffset.js"],"names":[],"mappings":"AAkEO,iDAJI,CAAC,CAAC,QAAQ,4BACV;IAAE,YAAY,EAAE,OAAO,mBAAmB,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,OAAO,mBAAmB,EAAE,MAAM,CAAA;CAAE,GAC5F,OAAO,CAQnB;;;;0BArDY;IACR,IAAI,EAAE,OAAO,GAAG,SAAS,CAAC;IAC1B,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;;;;uBAMS,eAAe,GAAG,eAAe,GAAG,cAAc,GAAG,cAAc,GAAG,eAAe,GAAG,cAAc;;;;;;;;;mBAWtG;IACR,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,CAAC,EAAE,OAAO,mBAAmB,EAAE,QAAQ,CAAC;IAC/C,WAAW,EAAE,WAAW,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACjC,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACzC;;;;;sBAOS,IAAI,EAAE;mBA3CA,YAAY"} +\ No newline at end of file diff --git a/dist/src/y-prosemirror.d.ts b/dist/src/y-prosemirror.d.ts deleted file mode 100644 index c1f9468c4c77434a1ad9f49227fb1274f5ae1915..0000000000000000000000000000000000000000 @@ -627,21 +741,20 @@ index 8eaef6bf2b216933047f528e3c3b0aa469df45e7..258a3b18cc50c11181b70a716953fdb1 "@rollup/plugin-commonjs": "^28.0.8", diff --git a/src/commands.js b/src/commands.js new file mode 100644 -index 0000000000000000000000000000000000000000..504167d4a50fbbb1198a3f9108edba262738504a +index 0000000000000000000000000000000000000000..6c4ff47a6cbe8a9cacc400af7755791daa8a0af1 --- /dev/null +++ b/src/commands.js -@@ -0,0 +1,163 @@ -+import * as d from 'lib0/delta' +@@ -0,0 +1,154 @@ +import { ySyncPluginKey, yUndoPluginKey } from './keys.js' -+import { deltaToPSteps, deltaAttributionToFormat, nodeToDelta, deltaToPNode } from './sync-utils.js' ++import { deltaToPSteps, nodeToDelta, deltaToPNode } from './sync-utils.js' ++import * as d from 'lib0/delta' +import * as Y from '@y/y' +import { absolutePositionToRelativePosition } from './positions.js' + +/** -+ * Switch to pause mode (stop synchronization between prosemirror and ytype) -+ * @param {import('prosemirror-state').EditorState} state -+ * @param {CommandDispatch?} dispatch -+ * @returns {boolean} ++ * Switch to pause mode (stop synchronization between prosemirror and ytype). ++ * ++ * @type {import('prosemirror-state').Command} + */ +export function pauseSync (state, dispatch) { + const pluginState = ySyncPluginKey.getState(state) @@ -656,17 +769,14 @@ index 0000000000000000000000000000000000000000..504167d4a50fbbb1198a3f9108edba26 + return true +} + -+const debugging = false -+ +/** + * Reconfigure y-prosemirror. + * - enable syncing to (different) ytype -+ * - render attributions + * - pause sync (by setting ytype=null) + * + * @param {object} [opts] -+ * @param {YType?} [opts.ytype] Sync different ytype. Set to null to pause sync -+ * @param {AttributionManager?} [opts.attributionManager] Optional attribution manager to switch to ++ * @param {Y.Type | null} [opts.ytype] Sync different ytype. Set to null to pause sync. ++ * @param {Y.AbstractAttributionManager | null} [opts.attributionManager] Optional attribution manager to switch to. + * @returns {import('prosemirror-state').Command} + */ +export const configureYProsemirror = (opts = {}) => (state, dispatch) => { @@ -680,18 +790,13 @@ index 0000000000000000000000000000000000000000..504167d4a50fbbb1198a3f9108edba26 + const tr = state.tr.setMeta(ySyncPluginKey, opts) + tr.setMeta('addToHistory', false) + if (ytype) { -+ /** -+ * @type {ProsemirrorDelta} -+ */ -+ const ycontent = deltaAttributionToFormat(ytype.toDeltaDeep(attributionManager || Y.noAttributionsManager), pluginState.attributionMapper) -+ // @todo it is preferred to apply the minimal diff - at least for debugging purposes. the -+ // document replacal is more reliable though -+ if (debugging) { -+ const pcontent = nodeToDelta(tr.doc, undefined, true) ++ const ycontent = ytype.toDeltaDeep() ++ try { ++ const pcontent = nodeToDelta(tr.doc) + const diff = d.diff(pcontent.done(), ycontent.done()) -+ deltaToPSteps(tr, diff, undefined, undefined, pluginState.attributedNodes) -+ } else { -+ tr.replaceWith(0, tr.doc.content.size, deltaToPNode(ycontent, tr.doc.type.schema, null, pluginState.attributedNodes)) ++ deltaToPSteps(tr, diff) ++ } catch { ++ tr.replaceWith(0, tr.doc.content.size, deltaToPNode(ycontent, tr.doc.type.schema, null)) + } + } + dispatch(tr) @@ -1143,11 +1248,273 @@ index 0000000000000000000000000000000000000000..79fa8f273361c11282e2c2df76c38895 + } + } + }) +diff --git a/src/diff-decorations.js b/src/diff-decorations.js +new file mode 100644 +index 0000000000000000000000000000000000000000..a6e6d6b271242dc7d7dbe9bdc814c10447880db4 +--- /dev/null ++++ b/src/diff-decorations.js +@@ -0,0 +1,256 @@ ++/** ++ * Render a `DiffSet` as ProseMirror decorations over the *displayed* (final) ++ * document. The document is never mutated. ++ * ++ * - inline-insert / inline-update -> Decoration.inline ++ * - block-insert / block-update -> Decoration.node (per top-level node) ++ * - inline-delete / block-delete -> Decoration.widget showing the removed ++ * content, reconstructed by serializing the removed Fragment to HTML. ++ * ++ * Each decoration carries its `diff` in the decoration `spec` so future ++ * node-views / attribute-change extraction can read it back. Decorations ++ * also expose `data-diff-type` and `data-diff-user-id` attributes for CSS. ++ */ ++import { Decoration, DecorationSet } from 'prosemirror-view' ++import { Plugin } from 'prosemirror-state' ++import { DOMSerializer, Fragment } from 'prosemirror-model' ++import { suggestionDiffPluginKey } from './keys.js' ++ ++/** ++ * @typedef {import('./y-attribution-to-diffset.js').Diff} Diff ++ * @typedef {import('./y-attribution-to-diffset.js').DiffSet} DiffSet ++ * @typedef {import('./y-attribution-to-diffset.js').DiffType} DiffType ++ * @typedef {import('./y-attribution-to-diffset.js').Attribution} Attribution ++ */ ++ ++/** ++ * Arguments passed to a `mapDiffToDecorations` callback. ++ * ++ * @typedef {{ ++ * diff: Diff, ++ * doc: import('prosemirror-model').Node, ++ * schema: import('prosemirror-model').Schema, ++ * index: number, ++ * color?: string, ++ * attributes?: import('prosemirror-view').DecorationAttrs, ++ * defaultMapDiffToDecorations?: MapDiffToDecorations ++ * }} MapDiffArgs ++ */ ++ ++/** ++ * Callback that converts a single `Diff` to decoration(s). ++ * Return a `Decoration`, an array of them, or `null` to skip. ++ * ++ * @callback MapDiffToDecorations ++ * @param {MapDiffArgs} args ++ * @returns {import('prosemirror-view').Decoration | import('prosemirror-view').Decoration[] | null} ++ */ ++ ++/** ++ * Options shared by `buildDiffDecorationSet` and `ySuggestionDecorationPlugin`. ++ * ++ * @typedef {{ ++ * colorForAuthors?: (authorIds: string[]) => (string | undefined), ++ * mapDiffToDecorations?: MapDiffToDecorations ++ * }} SuggestionDecorationOptions ++ */ ++ ++/** ++ * Reconstruct removed content as a non-editable DOM node by serializing the ++ * Fragment to HTML. Works for inline text and for whole block nodes alike. ++ * ++ * @param {import('prosemirror-model').Fragment} fragment ++ * @param {import('prosemirror-model').Schema} schema ++ * @param {{ authorIds?: string[], color?: string, title?: string }} [opts] ++ * @returns {HTMLElement} ++ */ ++export const renderDeletedContent = (fragment, schema, opts = {}) => { ++ const serializer = DOMSerializer.fromSchema(schema) ++ const isBlock = fragment?.firstChild?.isBlock ?? false ++ const container = document.createElement(isBlock ? 'div' : 'span') ++ container.className = 'pm-suggest pm-suggest--delete' ++ container.setAttribute('data-diff-type', isBlock ? 'block-delete' : 'inline-delete') ++ if (opts.authorIds?.length) { ++ container.setAttribute('data-diff-user-id', opts.authorIds.join(',')) ++ } ++ if (opts.color) { ++ container.style.setProperty('--author-color', opts.color) ++ } ++ if (opts.title) { ++ container.setAttribute('title', opts.title) ++ } ++ container.contentEditable = 'false' ++ if (fragment) { ++ container.appendChild(serializer.serializeFragment(fragment, { document })) ++ } ++ return container ++} ++ ++/** ++ * Build a human-readable hover title from diff attribution. ++ * ++ * @param {Diff} diff ++ * @returns {string} ++ */ ++const hoverTitle = (diff) => { ++ const parts = [] ++ const authorIds = diff.attribution.authorIds ++ if (authorIds.length) { ++ parts.push(authorIds.join(', ')) ++ } ++ if (diff.attribution.timestamp) { ++ parts.push(new Date(diff.attribution.timestamp).toLocaleString()) ++ } ++ const typeLabel = diff.type.replace('-', ' ') ++ if (parts.length) return `${typeLabel}: ${parts.join(' — ')}` ++ return typeLabel ++} ++ ++/** ++ * Build a summary string for a block-update diff showing what changed ++ * (e.g. "level: 1 → 2"). ++ * ++ * @param {Diff} diff ++ * @returns {string} ++ */ ++const blockUpdateSummary = (diff) => { ++ if (diff.type !== 'block-update') return '' ++ const attrs = diff.attributes ++ const prev = diff.previousAttributes ++ if (!attrs) return '' ++ const parts = [] ++ for (const key of Object.keys(attrs)) { ++ const newVal = attrs[key] ++ const oldVal = prev?.[key] ++ if (oldVal !== undefined && oldVal !== newVal) { ++ parts.push(`${key}: ${oldVal} → ${newVal}`) ++ } else { ++ parts.push(`${key}: ${newVal}`) ++ } ++ } ++ return parts.join(', ') ++} ++ ++/** ++ * @param {Diff} diff ++ * @param {{ authorIds: string[], color?: string }} ctx ++ * @returns {import('prosemirror-view').DecorationAttrs} ++ */ ++const decorationAttrs = (diff, { authorIds, color }) => { ++ /** @type {import('prosemirror-view').DecorationAttrs} */ ++ const attrs = { ++ class: `pm-suggest pm-suggest--${diff.type}`, ++ 'data-diff-type': diff.type ++ } ++ if (authorIds.length) attrs['data-diff-user-id'] = authorIds.join(',') ++ if (color) attrs.style = `--author-color: ${color}` ++ // Hover metadata: show author(s), timestamp, and attribute changes ++ let title = hoverTitle(diff) ++ const summary = blockUpdateSummary(diff) ++ if (summary) title += ` (${summary})` ++ attrs.title = title ++ return attrs ++} ++ ++/** ++ * Default mapping from a single `Diff` to decoration(s). Returns a `Decoration`, ++ * an array of them, or `null` to skip. ++ * ++ * @type {MapDiffToDecorations} ++ */ ++export const defaultMapDiffToDecorations = ({ diff, doc, schema, index, color, attributes = {} }) => { ++ const authorIds = diff.attribution.authorIds ++ const attrs = { ...decorationAttrs(diff, { authorIds, color }), ...attributes } ++ const spec = { diff } ++ ++ switch (diff.type) { ++ case 'inline-insert': ++ case 'inline-update': ++ return Decoration.inline(diff.from, diff.to, attrs, { ...spec, inclusiveStart: false, inclusiveEnd: true }) ++ ++ case 'block-update': ++ return Decoration.node(diff.from, diff.to, attrs, spec) ++ ++ case 'block-insert': { ++ const $from = doc.resolve(diff.from) ++ const after = $from.nodeAfter ++ if (after && diff.from + after.nodeSize === diff.to) { ++ return Decoration.node(diff.from, diff.to, attrs, spec) ++ } ++ /** @type {Decoration[]} */ ++ const decos = [] ++ doc.nodesBetween(diff.from, diff.to, (node, pos) => { ++ if (pos >= diff.from && pos + node.nodeSize <= diff.to && node.isBlock) { ++ decos.push(Decoration.node(pos, pos + node.nodeSize, attrs, spec)) ++ return false ++ } ++ return undefined ++ }) ++ if (!decos.length) { ++ decos.push(Decoration.inline(diff.from, diff.to, attrs, spec)) ++ } ++ return decos ++ } ++ ++ case 'inline-delete': ++ case 'block-delete': ++ return Decoration.widget( ++ diff.from, ++ () => renderDeletedContent(diff.content ?? Fragment.empty, schema, { authorIds, color, title: hoverTitle(diff) }), ++ { side: 1, key: `diff-del-${index}-${diff.content?.size ?? 0}`, diff } ++ ) ++ ++ default: ++ return null ++ } ++} ++ ++/** ++ * Build a `DecorationSet` for `diffs` over `doc`. Pure - does not touch the doc. ++ * ++ * @param {import('prosemirror-model').Node} doc ++ * @param {DiffSet} diffs ++ * @param {import('prosemirror-model').Schema} schema ++ * @param {SuggestionDecorationOptions} [opts] ++ * @returns {DecorationSet} ++ */ ++export const buildDiffDecorationSet = (doc, diffs, schema, opts = {}) => { ++ const map = opts.mapDiffToDecorations ?? defaultMapDiffToDecorations ++ /** @type {Decoration[]} */ ++ const decorations = [] ++ diffs.forEach((diff, index) => { ++ const color = opts.colorForAuthors?.(diff.attribution.authorIds) ++ const result = map({ diff, doc, schema, index, color, defaultMapDiffToDecorations }) ++ if (Array.isArray(result)) decorations.push(...result.filter(Boolean)) ++ else if (result) decorations.push(result) ++ }) ++ return DecorationSet.create(doc, decorations) ++} ++ ++/** ++ * ProseMirror plugin that overlays a `DiffSet` as decorations. ++ * ++ * Update the diffs at runtime by dispatching ++ * `tr.setMeta(suggestionDiffPluginKey, { diffs })`. ++ * ++ * @param {SuggestionDecorationOptions & { diffs?: DiffSet }} [config] ++ * @returns {Plugin} ++ */ ++export const suggestionDiffPlugin = ({ diffs = [], mapDiffToDecorations, colorForAuthors } = {}) => ++ new Plugin({ ++ key: suggestionDiffPluginKey, ++ state: { ++ init: (_config, state) => ++ buildDiffDecorationSet(state.doc, diffs, state.schema, { mapDiffToDecorations, colorForAuthors }), ++ apply: (tr, prev, _old, newState) => { ++ const meta = tr.getMeta(suggestionDiffPluginKey) ++ if (meta?.diffs) { ++ return buildDiffDecorationSet(newState.doc, meta.diffs, newState.schema, { mapDiffToDecorations, colorForAuthors }) ++ } ++ return prev.map(tr.mapping, tr.doc) ++ } ++ }, ++ props: { ++ decorations: (state) => suggestionDiffPluginKey.getState(state) ++ } ++ }) diff --git a/src/index.js b/src/index.js -index ac407e0c363309c970f3dbcbd66db00f9cd1656a..0c20333ce9f66f1a1e3e8e44da1ac4017bbba4cc 100644 +index ac407e0c363309c970f3dbcbd66db00f9cd1656a..a168ef17e646c9378899e4ff3b515740ec0b07eb 100644 --- a/src/index.js +++ b/src/index.js -@@ -1,627 +1,7 @@ +@@ -1,627 +1,17 @@ -import * as delta from 'lib0/delta' -import * as math from 'lib0/math' -import * as mux from 'lib0/mutex' @@ -1778,18 +2145,30 @@ index ac407e0c363309c970f3dbcbd66db00f9cd1656a..0c20333ce9f66f1a1e3e8e44da1ac401 +export * from './sync-plugin.js' +export * from './keys.js' +export * from './positions.js' -+export { docToDelta, $prosemirrorDelta, defaultMapAttributionToMark } from './sync-utils.js' ++export { docToDelta, $prosemirrorDelta } from './sync-utils.js' +export * from './commands.js' +export * from './undo-plugin.js' +export * from './cursor-plugin.js' ++export { ydeltaToDiffSet } from './y-attribution-to-diffset.js' ++/** @typedef {import('./y-attribution-to-diffset.js').Attribution} Attribution */ ++/** @typedef {import('./y-attribution-to-diffset.js').Diff} Diff */ ++/** @typedef {import('./y-attribution-to-diffset.js').DiffType} DiffType */ ++/** @typedef {import('./y-attribution-to-diffset.js').DiffSet} DiffSet */ ++export { buildDiffDecorationSet, suggestionDiffPlugin, renderDeletedContent, defaultMapDiffToDecorations } from './diff-decorations.js' ++/** @typedef {import('./diff-decorations.js').SuggestionDecorationOptions} SuggestionDecorationOptions */ ++/** @typedef {import('./diff-decorations.js').MapDiffToDecorations} MapDiffToDecorations */ ++/** @typedef {import('./diff-decorations.js').MapDiffArgs} MapDiffArgs */ ++export { ySuggestionDecorationPlugin } from './suggestion-decoration-plugin.js' diff --git a/src/keys.js b/src/keys.js new file mode 100644 -index 0000000000000000000000000000000000000000..7490849525d1ff00da44aa34b7588531d5f5fd7e +index 0000000000000000000000000000000000000000..5430d28aa03577d34dfdf647eee1096393f30ca0 --- /dev/null +++ b/src/keys.js -@@ -0,0 +1,25 @@ +@@ -0,0 +1,43 @@ +import { PluginKey } from 'prosemirror-state' // eslint-disable-line + ++/** @typedef {import('lib0/schema').Unwrap} SyncPluginState */ ++ +/** + * The unique prosemirror plugin key for {@link import('./sync-plugin.js').syncPlugin} + * @@ -1813,6 +2192,22 @@ index 0000000000000000000000000000000000000000..7490849525d1ff00da44aa34b7588531 + * @type {PluginKey} + */ +export const yCursorPluginKey = new PluginKey('y-cursor') ++ ++/** ++ * The unique prosemirror plugin key for {@link import('./suggestion-decoration-plugin.js').ySuggestionDecorationPlugin} ++ * ++ * @public ++ * @type {PluginKey} ++ */ ++export const ySuggestionDecorationPluginKey = new PluginKey('y-suggestion-decorations') ++ ++/** ++ * The unique prosemirror plugin key for {@link import('./diff-decorations.js').suggestionDiffPlugin} ++ * ++ * @public ++ * @type {PluginKey} ++ */ ++export const suggestionDiffPluginKey = new PluginKey('suggestion-diff') diff --git a/src/lib.js b/src/lib.js deleted file mode 100644 index 698f0c8c42ffed9804a2c13f48bd4c51f27794dc..0000000000000000000000000000000000000000 @@ -2046,21 +2441,100 @@ index 0000000000000000000000000000000000000000..963ea708dbe0e92b2d43fc031243c2e7 + } + } +} +diff --git a/src/suggestion-decoration-plugin.js b/src/suggestion-decoration-plugin.js +new file mode 100644 +index 0000000000000000000000000000000000000000..b6ba25f283511c74d4fd43456fe05651c980b9fd +--- /dev/null ++++ b/src/suggestion-decoration-plugin.js +@@ -0,0 +1,75 @@ ++/** ++ * ProseMirror plugin that renders Yjs attribution as decorations over a ++ * clean document. Follows the cursor-plugin pattern: a separate plugin ++ * with its own key, state, and decorations prop. ++ * ++ * Use alongside `syncPlugin()` which always syncs clean content (no ++ * attribution marks, no deleted text). This plugin reads the attributed ++ * delta separately and overlays suggestions as decorations. ++ * ++ * The sync plugin dispatches y-sync-transaction meta on remote Y changes ++ * and AM change events. This plugin rebuilds decorations in `apply` ++ * when it sees that meta or a doc change. ++ */ ++import * as Y from '@y/y' ++import { Plugin } from 'prosemirror-state' ++import { DecorationSet } from 'prosemirror-view' ++import { ySyncPluginKey, ySuggestionDecorationPluginKey } from './keys.js' ++import { ydeltaToDiffSet } from './y-attribution-to-diffset.js' ++import { buildDiffDecorationSet } from './diff-decorations.js' // eslint-disable-line ++/** @typedef {import('./diff-decorations.js').SuggestionDecorationOptions} SuggestionDecorationOptions */ ++ ++/** ++ * Build decorations from the Yjs attribution delta. ++ * ++ * @param {import('prosemirror-model').Node} doc ++ * @param {import('prosemirror-model').Schema} schema ++ * @param {Y.Type | null} ytype ++ * @param {Y.AbstractAttributionManager | null} am ++ * @param {SuggestionDecorationOptions} opts ++ * @returns {DecorationSet} ++ */ ++function computeDecorations (doc, schema, ytype, am, opts) { ++ if (!ytype || !am || am === Y.noAttributionsManager) { ++ return DecorationSet.empty ++ } ++ const attributedDelta = ytype.toDeltaDeep(am) ++ const diffs = ydeltaToDiffSet(attributedDelta, { displayedDoc: doc, schema }) ++ try { ++ return buildDiffDecorationSet(doc, diffs, schema, opts) ++ } catch (err) { ++ console.error('[y-prosemirror] decoration build failed:', err) ++ return DecorationSet.empty ++ } ++} ++ ++/** ++ * @param {SuggestionDecorationOptions} [opts] ++ * @returns {Plugin} ++ */ ++export const ySuggestionDecorationPlugin = (opts = {}) => ++ new Plugin({ ++ key: ySuggestionDecorationPluginKey, ++ state: { ++ init: () => DecorationSet.empty, ++ apply: (tr, prev, oldState, newState) => { ++ const ySyncMeta = tr.getMeta('y-sync-transaction') ++ const configMeta = tr.getMeta(ySyncPluginKey) ++ const metaOverride = configMeta || ySyncMeta ++ if (metaOverride) { ++ const baseSync = ySyncPluginKey.getState(oldState) || ySyncPluginKey.getState(newState) ++ const ystate = Object.assign({}, baseSync, metaOverride) ++ if (ystate?.attributionManager && ystate.attributionManager !== Y.noAttributionsManager) { ++ return computeDecorations( ++ newState.doc, newState.schema, ystate.ytype, ystate.attributionManager, opts ++ ) ++ } ++ } ++ if (tr.docChanged) return prev.map(tr.mapping, tr.doc) ++ return prev ++ } ++ }, ++ props: { ++ decorations: (state) => ySuggestionDecorationPluginKey.getState(state) ++ } ++ }) diff --git a/src/sync-plugin.js b/src/sync-plugin.js new file mode 100644 -index 0000000000000000000000000000000000000000..079bc7e465f98612907d36adc9854054814dda91 +index 0000000000000000000000000000000000000000..e18bf04380f182a7cf9e14b74d2d39c9044b7320 --- /dev/null +++ b/src/sync-plugin.js -@@ -0,0 +1,301 @@ +@@ -0,0 +1,211 @@ +import * as Y from '@y/y' +import { Plugin } from 'prosemirror-state' +import { -+ $prosemirrorDelta, -+ defaultAttributedNodes, -+ defaultMapAttributionToMark, -+ deltaAttributionToFormat, + deltaToPSteps, -+ nodeToDelta ++ docDiffToDelta, ++ nodeToDelta, ++ stepToDelta +} from './sync-utils.js' +import * as d from 'lib0/delta' +import { ySyncPluginKey } from './keys.js' @@ -2068,88 +2542,27 @@ index 0000000000000000000000000000000000000000..079bc7e465f98612907d36adc9854054 +import * as object from 'lib0/object' + +/** -+ * The y-prosemirror binding is a bi-directional synchronization with the provided Y.Type and the EditorView ++ * The y-prosemirror binding is a bi-directional synchronization with the provided Y.Type and the EditorView. + * Any change applied to the EditorView will be applied (via deltas) to the Y.Type, and vice versa. ++ * ++ * The PM document always contains **clean** content — no attribution marks, ++ * no deleted text inline. Attribution is rendered as decorations by ++ * {@link import('./suggestion-decoration-plugin.js').ySuggestionDecorationPlugin}. + */ +export const $syncPluginState = s.$object({ + ytype: Y.$ytypeAny.nullable, -+ /** -+ * If provided, will switch to the given attribution manager instead of the current attribution manager -+ */ -+ attributionManager: Y.$attributionManager.nullable, -+ attributionMapper: /** @type {s.Schema} */ (s.$function), -+ /** -+ * Predicate deciding which attributed nodes render under their -+ * `{nodeName}--attributed` variant. See {@link syncPlugin}. -+ */ -+ attributedNodes: /** @type {s.Schema} */ (s.$function) ++ attributionManager: Y.$attributionManager.nullable +}) + +export const $syncPluginStateUpdate = s.$object({ + ytype: Y.$ytypeAny.nullable.optional, -+ attributionManager: Y.$attributionManager.nullable.optional, -+ attributionMapper: /** @type {s.Schema} */ (s.$function).nullable.optional, -+ attributedNodes: /** @type {s.Schema} */ (s.$function).nullable.optional, -+ change: /** @type {s.Schema>} */ (s.$any).nullable.optional ++ attributionManager: Y.$attributionManager.nullable.optional +}) +const $maybeSyncPluginStateUpdate = $syncPluginStateUpdate.nullable + -+const attributedDeleteMark = 'y-attributed-delete' -+const attributionMarkNames = [ -+ 'y-attributed-insert', -+ 'y-attributed-format', -+ attributedDeleteMark -+] -+ -+/** -+ * Strip attribution-mark formats (`y-attributed-*`). Returns a fresh -+ * delta - **never mutates** the input. `lib0/delta.diff` reuses op -+ * references (and nested delta references) from its inputs, so an -+ * in-place mutation here would also mutate `pcontent`/`desiredPM` and -+ * corrupt subsequent diff calls. `lib0/delta.clone` only deep-clones -+ * the top level - nested deltas inside an `InsertOp.insert` array stay -+ * shared by reference - so cloning then mutating is also unsafe. -+ * -+ * @param {d.DeltaAny} input -+ * @returns {d.DeltaAny} -+ */ -+const stripAttributionFormattingFromDelta = (input) => { -+ /** @param {Record | null | undefined} format */ -+ const stripFormat = (format) => { -+ if (format == null) return format -+ /** @type {Record} */ -+ const out = {} -+ for (const k in format) { -+ if (!attributionMarkNames.includes(k)) out[k] = format[k] -+ } -+ return out -+ } -+ const out = /** @type {any} */ (d.create(input.name, $prosemirrorDelta)) -+ for (const attr of input.attrs) { -+ // @ts-ignore -+ out.attrs[attr.key] = attr.clone() -+ } -+ for (const child of input.children) { -+ if (d.$retainOp.check(child)) { -+ out.retain(child.retain, stripFormat(child.format)) -+ } else if (d.$textOp.check(child)) { -+ out.insert(child.insert, stripFormat(child.format)) -+ } else if (d.$insertOp.check(child)) { -+ const newInsert = child.insert.map(ins => -+ d.$deltaAny.check(ins) ? stripAttributionFormattingFromDelta(ins) : ins -+ ) -+ out.insert(newInsert, stripFormat(child.format)) -+ } else if (d.$deleteOp.check(child)) { -+ out.delete(child.delete) -+ } else if (d.$modifyOp.check(child)) { -+ out.modify(stripAttributionFormattingFromDelta(child.value), stripFormat(child.format)) -+ } -+ } -+ return out.done(false) -+} -+ +/** -+ * This Prosemirror {@link Plugin} is responsible for synchronizing the prosemirror {@link EditorState} with a {@link Y.XmlFragment} ++ * This Prosemirror {@link Plugin} is responsible for synchronizing the prosemirror ++ * {@link import('prosemirror-state').EditorState} with a {@link Y.XmlFragment}. + * + * The PM->Y diff/apply pipeline runs in the plugin's `view().update` + * hook (i.e. after the dispatch has been committed to the view), not @@ -2157,10 +2570,12 @@ index 0000000000000000000000000000000000000000..079bc7e465f98612907d36adc9854054 + * cause speculative `state.apply` callers to write to Y as a side + * effect. + * -+ * @param {object} opts -+ * @param {Y.Doc} [opts.suggestionDoc] A {@link Y.Doc} to use for suggestion tracking -+ * @param {AttributionMapper} [opts.mapAttributionToMark] A function to map the {@link Y.Attribution} to a {@link import('prosemirror-model').Mark} - the mark names *must* be one of: `y-attributed-insert`, `y-attributed-delete`, `y-attributed-format`. No other mark names are permitted -+ * @param {AttributedNodesPredicate} [opts.attributedNodes] Optional predicate `(nodeName, kinds) => boolean`. When it returns `true` for an attributed node *and* a `{nodeName}--attributed` type exists in the schema, that node is rendered under the variant type (the `y-attributed-*` marks are still applied). `kinds` is `{ insert?, delete?, format? }`. The variant is a pure rendering concern - the canonical name is what is stored in the Y document. The predicate must be deterministic in `(nodeName, kinds)`. ++ * The PM document always mirrors the **clean** Y content (no attribution ++ * marks, no deleted text). The write path applies diffs through the AM ++ * so edits are tagged as suggestions. Attribution rendering is handled ++ * by the separate {@link import('./suggestion-decoration-plugin.js').ySuggestionDecorationPlugin}. ++ * ++ * @param {object} [opts] + * @returns {Plugin} + */ +export function syncPlugin (opts = {}) { @@ -2170,78 +2585,47 @@ index 0000000000000000000000000000000000000000..079bc7e465f98612907d36adc9854054 + init: () => { + return $syncPluginState.expect({ + ytype: null, -+ attributionManager: null, -+ attributionMapper: opts.mapAttributionToMark || defaultMapAttributionToMark, -+ attributedNodes: opts.attributedNodes || defaultAttributedNodes ++ attributionManager: null + }) + }, + apply: (tr, prevPluginState) => { + const stateUpdate = $maybeSyncPluginStateUpdate.expect(tr.getMeta(ySyncPluginKey) || null) ++ const prev = /** @type {any} */ (prevPluginState) ++ const isSyncTr = tr.getMeta('y-sync-transaction') != null || tr.getMeta(ySyncPluginKey) != null ++ const pendingTrs = isSyncTr ? [] : [...(prev.pendingTrs || []), tr] + if (!stateUpdate) { -+ return prevPluginState ++ return /** @type {any} */ ({ ...prev, pendingTrs }) + } -+ return object.assign({}, prevPluginState, stateUpdate, stateUpdate.attributionManager == null ? { attributionManager: Y.noAttributionsManager } : {}) ++ return /** @type {any} */ (object.assign({}, prev, stateUpdate, stateUpdate.attributionManager == null ? { attributionManager: Y.noAttributionsManager } : {}, { pendingTrs })) + } + }, + view () { + /** @type {(() => void) | null} */ + let unsubscribeFn = null + /** -+ * Subscribe to ytype changes and apply remote updates to prosemirror ++ * Subscribe to ytype changes and apply remote updates to prosemirror. + * @param {object} opts + * @param {import('prosemirror-view').EditorView} opts.view + * @param {Y.Type?} opts.ytype + * @param {Y.AbstractAttributionManager?} opts.attributionManager -+ * @param {AttributionMapper} opts.attributionMapper -+ * @param {AttributedNodesPredicate} opts.attributedNodes + */ -+ function subscribeToYType ({ view, ytype, attributionManager, attributionMapper, attributedNodes }) { ++ function subscribeToYType ({ view, ytype, attributionManager }) { + unsubscribeFn?.() + if (ytype != null) { -+ // Listen on the doc's `afterTransaction` event rather than -+ // `ytype.observeDeep`. `observeDeep` skips firing for any -+ // changes whose path runs through a *deleted* parent type -+ // (Y.js `Transaction._callObserver` short-circuits when -+ // `parent._item.deleted`). That happens in suggestion-mode -+ // when one peer suggestion-deletes a paragraph and another -+ // peer then inserts into it - the integrate path leaves the -+ // root deep observer silent, so the PM view never reconciles -+ // and goes stale (see `testCohortReplayConvergesAfterInsert -+ // IntoSuggestionDeletedParagraph`). `afterTransaction` fires -+ // unconditionally, so the reconcile pass always runs. + /** @type {Y.Doc} */ + const ydoc = /** @type {Y.Doc} */ (ytype.doc) + const onAfterTransaction = (/** @type {any} */ tr) => { + if (!view || view.isDestroyed) { + return unsubscribeFn?.() + } -+ // Skip changes we wrote ourselves from `view().update` -+ // - the PM->Y commit there already handled the reconcile -+ // dispatch in the same call. + if (/** @type {any} */ (tr).origin === ySyncPluginKey.get(view.state)) return -+ // Same pipeline as the PM->Y sync in `view().update`: -+ // render ytype through the AM, diff against the current PM doc, -+ // apply only the difference. Using `change.getDelta` here -+ // produced wrong/asymmetric output for some interleavings -+ // (notably commits-to-base from one peer that touched suggestion -+ // overlays from another), causing PM views to diverge from each -+ // other and from the canonical AM render. The full re-render is -+ // more expensive per update but is the only diff target all -+ // peers agree on. -+ const am = attributionManager || Y.noAttributionsManager -+ const desiredPM = deltaAttributionToFormat( -+ ytype.toDeltaDeep(am), -+ attributionMapper -+ ).done() -+ const pcontent = nodeToDelta(view.state.doc, undefined, true).done() ++ const desiredPM = ytype.toDeltaDeep().done() ++ const pcontent = nodeToDelta(view.state.doc).done() + const diff = d.diff(pcontent, desiredPM) -+ if (diff.isEmpty()) return -+ const ptr = deltaToPSteps(view.state.tr, diff, undefined, undefined, attributedNodes) ++ const ptr = diff.isEmpty() ? view.state.tr : deltaToPSteps(view.state.tr, diff) + ptr.setMeta('addToHistory', false) + ptr.setMeta('y-sync-transaction', $syncPluginStateUpdate.expect({ -+ change: null, + attributionManager, -+ attributionMapper, + ytype + })) + view.dispatch(ptr) @@ -2251,26 +2635,13 @@ index 0000000000000000000000000000000000000000..079bc7e465f98612907d36adc9854054 + if (!view || view.isDestroyed) { + return unsubscribeFn?.() + } -+ // Same pipeline as the PM->Y sync in `view().update`: -+ // render ytype through the AM, diff against the current PM doc, -+ // apply only the difference. We give up the `itemsToRender` -+ // targeted-rerender optimization in exchange for going through -+ // the same path that the rest of the plugin uses, which keeps -+ // the deltas shallow (only what actually changed). -+ const desiredPM = deltaAttributionToFormat( -+ ytype.toDeltaDeep(attributionManager || Y.noAttributionsManager), -+ attributionMapper -+ ).done() -+ const pcontent = nodeToDelta(view.state.doc, undefined, true).done() ++ const desiredPM = ytype.toDeltaDeep().done() ++ const pcontent = nodeToDelta(view.state.doc).done() + const diff = d.diff(pcontent, desiredPM) -+ if (diff.isEmpty()) return -+ const ptr = deltaToPSteps(view.state.tr, diff, undefined, undefined, attributedNodes) ++ const ptr = diff.isEmpty() ? view.state.tr : deltaToPSteps(view.state.tr, diff) + ptr.setMeta('addToHistory', false) -+ // @todo stop updating meta on every transaction + ptr.setMeta('y-sync-transaction', $syncPluginStateUpdate.expect({ -+ change: null, // @todo - remove this property + attributionManager, -+ attributionMapper, + ytype + })) + view.dispatch(ptr) @@ -2293,55 +2664,70 @@ index 0000000000000000000000000000000000000000..079bc7e465f98612907d36adc9854054 + const ytypeChanged = prevYtype !== ytype + const attributionManagerChanged = prevAttributionManager !== attributionManager + if (ytypeChanged || attributionManagerChanged) { -+ // Subscribe to the new ytype/attributionManager -+ // (subscribeToYType will automatically unsubscribe from previous if needed) -+ subscribeToYType({ -+ view, -+ ytype, -+ attributionManager, -+ attributionMapper: pluginState.attributionMapper, -+ attributedNodes: pluginState.attributedNodes -+ }) ++ subscribeToYType({ view, ytype, attributionManager }) + } + if (ytype == null) return + if (view.state.doc === prevState.doc) return -+ // PM->Y diff/apply pipeline. Runs after the dispatch is -+ // committed to the view, so speculative `state.apply` calls -+ // do not write to Y. The Y `afterTransaction` observer -+ // skips the write we make here via the origin check. The -+ // AM `change` handler may, however, dispatch its own -+ // reconcile synchronously during `transact` - so we -+ // re-read `pcontent` from `view.state.doc` after the write -+ // before computing our own reconcile, otherwise we'd -+ // apply the same insert twice. + const am = attributionManager || Y.noAttributionsManager -+ const mapper = pluginState.attributionMapper -+ const attributedNodes = pluginState.attributedNodes -+ const ycontent = deltaAttributionToFormat( -+ ytype.toDeltaDeep(am), -+ mapper -+ ).done() -+ const pcontent = nodeToDelta(view.state.doc, undefined, true).done() -+ const pmToYDiff = stripAttributionFormattingFromDelta(d.diff(ycontent, pcontent)) -+ if (!pmToYDiff.isEmpty()) { ++ const pendingTrs = /** @type {any} */ (pluginState).pendingTrs || [] ++ // The diff is in "clean" coordinates where AM-deleted items are ++ // invisible. But applyDelta navigates Y items via ++ // am.contentLength(), and a DiffAttributionManager counts ++ // AM-deleted items at full length — so retain counts land at ++ // wrong positions. This proxy overrides contentLength and ++ // readContent to use clean counting (deleted items = 0) while ++ // passing everything else through to the real AM. ++ // ++ // The proxy's distinct identity (≠ noAttributionsManager) keeps ++ // applyDelta's attribution-aware code paths active. Attribution ++ // recording is unaffected: the DiffAttributionManager's listener ++ // on the Y.Doc fires based on the transaction, not the AM passed ++ // to applyDelta. ++ const navAM = am === Y.noAttributionsManager ++ ? am ++ : new Proxy(am, { ++ get (target, prop, receiver) { ++ if (prop === 'contentLength') return Y.noAttributionsManager.contentLength ++ if (prop === 'readContent') return Y.noAttributionsManager.readContent ++ return Reflect.get(target, prop, receiver) ++ } ++ }) ++ const docChangedTrs = pendingTrs.filter(/** @param {any} ptr */ ptr => ptr.docChanged) ++ if (am === Y.noAttributionsManager && ++ docChangedTrs.length > 0 && ++ docChangedTrs[0].before === prevState.doc) { + /** @type {Y.Doc} */ (ytype.doc).transact(() => { -+ ytype.applyDelta(pmToYDiff, am) ++ for (const ptr of docChangedTrs) { ++ if (ptr.steps.length === 1) { ++ ytype.applyDelta(stepToDelta(ptr.steps[0], ptr.docs[0]), navAM) ++ } else { ++ ytype.applyDelta(docDiffToDelta(ptr.before, ptr.doc), navAM) ++ } ++ } + }, ySyncPluginKey.get(view.state)) ++ } else { ++ const ycontent = ytype.toDeltaDeep().done() ++ const pcontent = nodeToDelta(view.state.doc).done() ++ const pmToYDiff = d.diff(ycontent, pcontent) ++ if (!pmToYDiff.isEmpty()) { ++ /** @type {Y.Doc} */ (ytype.doc).transact(() => { ++ ytype.applyDelta(pmToYDiff, navAM) ++ }, ySyncPluginKey.get(view.state)) ++ } + } -+ const desiredPM = deltaAttributionToFormat( -+ ytype.toDeltaDeep(am), -+ mapper -+ ).done() -+ const pcontentAfter = nodeToDelta(view.state.doc, undefined, true).done() ++ // Reconcile: ensure PM matches the clean Y render after the write. ++ // Always dispatch y-sync-transaction meta so the decoration plugin ++ // can rebuild (even if the reconcile diff is empty). ++ const desiredPM = ytype.toDeltaDeep().done() ++ const pcontentAfter = nodeToDelta(view.state.doc).done() + const pmReconcileDiff = d.diff(pcontentAfter, desiredPM) -+ if (pmReconcileDiff.isEmpty()) return + const tr = view.state.tr -+ deltaToPSteps(tr, pmReconcileDiff, undefined, undefined, attributedNodes) ++ if (!pmReconcileDiff.isEmpty()) { ++ deltaToPSteps(tr, pmReconcileDiff) ++ } + tr.setMeta('addToHistory', false) + tr.setMeta('y-sync-transaction', $syncPluginStateUpdate.expect({ -+ change: null, + attributionManager, -+ attributionMapper: mapper, + ytype + })) + view.dispatch(tr) @@ -2355,18 +2741,16 @@ index 0000000000000000000000000000000000000000..079bc7e465f98612907d36adc9854054 +} diff --git a/src/sync-utils.js b/src/sync-utils.js new file mode 100644 -index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d887b38d28 +index 0000000000000000000000000000000000000000..bf96bea9f7382248ba10906e1da92a88d04908e7 --- /dev/null +++ b/src/sync-utils.js -@@ -0,0 +1,752 @@ +@@ -0,0 +1,580 @@ +import * as Y from '@y/y' -+import * as array from 'lib0/array' +import * as delta from 'lib0/delta' -+import * as error from 'lib0/error' +import * as math from 'lib0/math' +import * as object from 'lib0/object' +import * as s from 'lib0/schema' -+import { Node, Slice, Fragment } from 'prosemirror-model' ++import { Node, NodeRange, Slice, Fragment } from 'prosemirror-model' +import { + AddMarkStep, + AddNodeMarkStep, @@ -2380,156 +2764,7 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + +export const $prosemirrorDelta = delta.$delta({ name: s.$string, attrs: s.$record(s.$string, s.$any), text: true, recursiveChildren: true }) + -+/** -+ * Suffix appended to a node name when it is rendered as its "attributed -+ * variant" (see `attributedNodes` on {@link syncPlugin}). The suffix is fixed -+ * so that canonicalizing back (PM -> Y) is a pure string operation and can -+ * never drift from the forward mapping. `--attributed` is a *reserved* suffix: -+ * a real node type literally ending in it would be canonicalized away on the -+ * way to Y. -+ */ -+export const ATTRIBUTED_SUFFIX = '--attributed' -+ -+/** -+ * Default `attributedNodes` predicate - the feature is off, so every node keeps -+ * its canonical name. -+ * -+ * @type {AttributedNodesPredicate} -+ */ -+export const defaultAttributedNodes = () => false -+ -+/** -+ * Strip the {@link ATTRIBUTED_SUFFIX} so a PM node name maps back to the -+ * canonical name stored in the Y document. Identity for canonical names. -+ * -+ * @param {string} name -+ * @return {string} -+ */ -+export const canonicalNodeName = (name) => -+ name.endsWith(ATTRIBUTED_SUFFIX) -+ ? name.slice(0, -ATTRIBUTED_SUFFIX.length) -+ : name -+ -+/** -+ * Resolve the PM node name to render for `canonicalName` given the attribution -+ * carried in `format`. Returns `canonicalName + ATTRIBUTED_SUFFIX` when the -+ * `attributedNodes` predicate opts in *and* the variant exists in the schema; -+ * otherwise returns `canonicalName` unchanged. -+ * -+ * @param {string} canonicalName -+ * @param {Record | null | undefined} format -+ * @param {AttributedNodesPredicate} attributedNodes -+ * @param {import('prosemirror-model').Schema} schema -+ * @return {string} -+ */ -+export const attributedVariant = (canonicalName, format, attributedNodes, schema) => { -+ const kinds = { -+ insert: format?.['y-attributed-insert'] != null, -+ delete: format?.['y-attributed-delete'] != null, -+ format: format?.['y-attributed-format'] != null -+ } -+ if ((kinds.insert || kinds.delete || kinds.format) && attributedNodes(canonicalName, kinds)) { -+ const variant = canonicalName + ATTRIBUTED_SUFFIX -+ if (schema.nodes[variant] != null) return variant -+ } -+ return canonicalName -+} -+ -+/** -+ * Default attribution-to-mark mapper. -+ * -+ * **The mark names are part of `y-prosemirror`'s public contract and cannot be -+ * changed.** A custom `mapAttributionToMark` may return a different *value* -+ * (different attrs, omit some attribution kinds, etc.), but it must use the -+ * exact mark names below - other internals reference them by name and will not -+ * find marks named anything else: -+ * -+ * - `y-attributed-insert` -+ * - `y-attributed-delete` -+ * - `y-attributed-format` -+ * -+ * The integrator's ProseMirror schema must (a) define mark types with exactly -+ * these names and (b) ensure they are allowed on every node where attribution -+ * marks may land. See `CAVEATS.md` ("Attribution mark names are fixed") for the -+ * full rationale and the schema gotcha around mark-group resolution. -+ * -+ * Note: a single op may carry multiple attribution kinds simultaneously -+ * (e.g. inserted text whose format was also suggested), so the mapper sets -+ * each applicable mark independently rather than picking one. Absent kinds -+ * are not added to the format object - the diff layer naturally produces a -+ * format-remove when comparing PM content (where a stale mark is present) -+ * against the freshly-rendered AM delta (where the key is absent). -+ * -+ * @template {import('lib0/delta').Attribution} T -+ * @param {Record | null} format -+ * @param {T} attribution -+ * @returns {Record | null} -+ */ -+export const defaultMapAttributionToMark = (format, attribution) => { -+ const out = /** @type {Record} */ (object.assign({}, format)) -+ // Set each attribution kind that is present. Do NOT explicitly null out -+ // the absent kinds: lib0/delta's diff naturally produces a format-remove -+ // when comparing pcontent (where the mark is present) with desiredPM -+ // (where the key is absent). Including explicit `null` here would change -+ // the delta op's fingerprint and prevent the diff from matching ops by -+ // content, causing spurious text-node splits. -+ if (attribution.insert) { -+ out['y-attributed-insert'] = { -+ userIds: attribution.insert, -+ timestamp: attribution.insertAt ?? null -+ } -+ } -+ if (attribution.delete) { -+ out['y-attributed-delete'] = { -+ userIds: attribution.delete, -+ timestamp: attribution.deleteAt ?? null -+ } -+ } -+ if (attribution.format) { -+ // `userIdsByAttr` keeps the per-format-key authorship for callers that -+ // need it; `userIds` is the deduped union across all format keys for -+ // callers that just want "who suggested any format on this span". -+ out['y-attributed-format'] = { -+ userIds: array.unique(object.map(attribution.format, v => v).flat()), -+ userIdsByAttr: attribution.format, -+ timestamp: attribution.formatAt ?? null -+ } -+ } -+ return out -+} -+ -+/** -+ * Transform delta with attributions to delta with formats (marks). -+ * @param {delta.DeltaAny} d -+ * @param {function} attributionsToFormat -+ */ -+export const deltaAttributionToFormat = (d, attributionsToFormat) => { -+ const r = delta.create(d.name, $prosemirrorDelta) -+ for (const attr of d.attrs) { -+ // @ts-ignore -+ r.attrs[attr.key] = attr.clone() -+ } -+ for (const child of d.children) { -+ if (delta.$deleteOp.check(child)) { -+ r.delete(child.delete) -+ } else { -+ const format = child.attribution ? attributionsToFormat(child.format, child.attribution) : child.format -+ if (delta.$insertOp.check(child)) { -+ r.insert(child.insert.map(c => delta.$deltaAny.check(c) ? deltaAttributionToFormat(c, attributionsToFormat) : c), format) -+ } else if (delta.$textOp.check(child)) { -+ r.insert(child.insert, format) -+ } else if (delta.$retainOp.check(child)) { -+ r.retain(child.retain, format) -+ } else if (delta.$modifyOp.check(child)) { -+ // @ts-ignore -+ r.modify(/** @type {any} */ (deltaAttributionToFormat(child.value, attributionsToFormat)), format) -+ } else { -+ error.unexpectedCase() -+ } -+ } -+ } -+ return /** @type {ProsemirrorDelta} */ (r.done(false)) -+} ++/** @typedef {import('lib0/schema').Unwrap} ProsemirrorDelta */ + +/** + * @param {readonly import('prosemirror-model').Mark[]} marks @@ -2582,75 +2817,22 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + * @returns {Y.Type} + */ +export function pmToFragment (node, fragment, { attributionManager = Y.noAttributionsManager } = {}) { -+ // Canonicalize so the Y document never stores an attributed-variant name -+ // (`--attributed` is a reserved suffix - identity when no variant is present). -+ const initialPDelta = nodeToDelta(node, undefined, true).done() ++ const initialPDelta = nodeToDelta(node).done() + fragment.applyDelta(initialPDelta, attributionManager) + + return fragment +} + +/** -+ * Applies a {@link Y.XmlFragment}'s content as a ProseMirror {@link Transaction} -+ * @param {Y.Type} fragment -+ * @param {import('prosemirror-state').Transaction} tr -+ * @param {object} ctx -+ * @param {Y.AbstractAttributionManager} [ctx.attributionManager] -+ * @param {typeof defaultMapAttributionToMark} [ctx.mapAttributionToMark] -+ * @param {AttributedNodesPredicate} [ctx.attributedNodes] -+ * @returns {import('prosemirror-state').Transaction} -+ */ -+export function fragmentToTr (fragment, tr, { -+ attributionManager = Y.noAttributionsManager, -+ mapAttributionToMark = defaultMapAttributionToMark, -+ attributedNodes = defaultAttributedNodes -+} = {}) { -+ const fragmentContent = deltaAttributionToFormat( -+ fragment.toDelta(attributionManager, { deep: true }), -+ mapAttributionToMark -+ ) -+ const initialPDelta = nodeToDelta(tr.doc, undefined, true).done() -+ const deltaBetweenPmAndFragment = delta.diff(initialPDelta, fragmentContent).done() -+ -+ return deltaToPSteps(tr, deltaBetweenPmAndFragment, undefined, undefined, attributedNodes).setMeta('y-sync-hydration', { -+ delta: deltaBetweenPmAndFragment -+ }) -+} -+ -+/** -+ * Transforms a {@link Y.XmlFragment} into a {@link Node} -+ * @param {Y.Type} fragment -+ * @param {import('prosemirror-state').Transaction} tr -+ * @return {Node} -+ */ -+export function fragmentToPm (fragment, tr) { -+ return fragmentToTr(fragment, tr).doc -+} -+ -+/** + * @param {Node} n + * @param {string?} nodeName -+ * @param {boolean} [canonicalize] When `true`, the emitted name has the -+ * {@link ATTRIBUTED_SUFFIX} stripped (PM -> Y direction). The flag propagates -+ * through the child recursion. + * @return {ProsemirrorDelta} + */ -+export const nodeToDelta = (n, nodeName = n.type.name, canonicalize = false) => { -+ const d = delta.create(canonicalize && nodeName != null ? canonicalNodeName(nodeName) : nodeName, $prosemirrorDelta) -+ // `y-attributed` is a render-only marker injected when a node is rendered -+ // under its `--attributed` variant (see the injections in `applyNodeFormat` -+ // and `deltaToPNode`). It must never persist in Y - strip it on the PM->Y -+ // (canonicalize) path, symmetric to the variant-name canonicalization above. -+ // Otherwise Y stores a canonical node carrying `y-attributed`, which the -+ // canonical PM type cannot round-trip, and the reconcile loop never converges. -+ if (canonicalize && n.attrs['y-attributed'] !== undefined) { -+ const { 'y-attributed': _omit, ...rest } = n.attrs -+ d.setAttrs(rest) -+ } else { -+ d.setAttrs(n.attrs) -+ } ++export const nodeToDelta = (n, nodeName = n.type.name) => { ++ const d = delta.create(nodeName, $prosemirrorDelta) ++ d.setAttrs(n.attrs) + n.content.content.forEach(c => { -+ d.insert(c.isText ? (c.text ?? []) : [nodeToDelta(c, undefined, canonicalize)], marksToFormattingAttributes(c.marks)) ++ d.insert(c.isText ? (c.text ?? []) : [nodeToDelta(c)], marksToFormattingAttributes(c.marks)) + }) + return d.done(false) +} @@ -2661,44 +2843,21 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 +export const docToDelta = doc => nodeToDelta(doc, null) + +/** -+ * Apply node-level format (node marks) at `pos`. When the resulting attribution -+ * marks change the node's {@link attributedVariant}, flip the node type with a -+ * single size-preserving `setNodeMarkup` (which also sets the resulting mark -+ * set atomically - this avoids an intermediate state where the canonical type -+ * would carry a mark it does not declare). Otherwise this is byte-identical to -+ * the previous per-key `addNodeMark`/`removeNodeMark` loop. ++ * Apply node-level format (node marks) at `pos`. + * + * @param {import('prosemirror-state').Transaction} tr + * @param {number} pos + * @param {Record | null | undefined} format -+ * @param {AttributedNodesPredicate} attributedNodes + */ -+const applyNodeFormat = (tr, pos, format, attributedNodes) => { ++const applyNodeFormat = (tr, pos, format) => { + const schema = tr.doc.type.schema -+ const node = tr.doc.nodeAt(pos) -+ if (node == null) return -+ let resultingMarks = node.marks + object.forEach(format ?? {}, (v, k) => { -+ const markType = schema.marks[k] -+ if (markType == null) return -+ resultingMarks = v == null -+ ? markType.removeFromSet(resultingMarks) -+ : schema.mark(k, v).addToSet(resultingMarks) ++ if (v == null) { ++ tr.removeNodeMark(pos, schema.marks[k]) ++ } else { ++ tr.addNodeMark(pos, schema.mark(k, v)) ++ } + }) -+ const targetType = schema.nodes[ -+ attributedVariant(canonicalNodeName(node.type.name), marksToFormattingAttributes(resultingMarks), attributedNodes, schema) -+ ] -+ if (targetType !== node.type) { -+ tr.setNodeMarkup(pos, targetType, object.assign({ 'y-attributed': true }, node.attrs), resultingMarks) -+ } else { -+ object.forEach(format ?? {}, (v, k) => { -+ if (v == null) { -+ tr.removeNodeMark(pos, schema.marks[k]) -+ } else { -+ tr.addNodeMark(pos, schema.mark(k, v)) -+ } -+ }) -+ } +} + +/** @@ -2722,10 +2881,9 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + * @param {ProsemirrorDelta} d + * @param {Node} [pnode] + * @param {{ i: number }} [currPos] -+ * @param {AttributedNodesPredicate} [attributedNodes] + * @return {import('prosemirror-state').Transaction} + */ -+export const deltaToPSteps = (tr, d, pnode = tr.doc, currPos = { i: 0 }, attributedNodes = defaultAttributedNodes) => { ++export const deltaToPSteps = (tr, d, pnode = tr.doc, currPos = { i: 0 }) => { + const schema = tr.doc.type.schema + let currParentIndex = 0 + let nOffset = 0 @@ -2760,6 +2918,9 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + runInserts = [] + runDeletes = [] + } ++ // @ts-ignore TS2589: tsc hits "excessively deep" expanding the recursive ++ // `$prosemirrorDelta` op type while iterating; the `delta.$*Op.check` guards ++ // below re-narrow each op precisely. + for (const op of d.children) { + if (delta.$retainOp.check(op) || delta.$modifyOp.check(op)) { + flushRun() @@ -2805,21 +2966,21 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + } + } else { + // TODO see schema.js for more info on marking nodes -+ applyNodeFormat(tr, currPos.i, op.format, attributedNodes) ++ applyNodeFormat(tr, currPos.i, op.format) + currParentIndex++ + currPos.i += pc.nodeSize + i-- + } + } + } else if (delta.$modifyOp.check(op)) { -+ applyNodeFormat(tr, currPos.i, op.format, attributedNodes) ++ applyNodeFormat(tr, currPos.i, op.format) + const child = pchildren[currParentIndex++] + const childStart = currPos.i + // Snapshot `tr.doc.content.size` so we can detect inserts/deletes + // appended inside the recursion below. + const sizeBefore = tr.doc.content.size + currPos.i = childStart + 1 -+ deltaToPSteps(tr, op.value, child, currPos, attributedNodes) ++ deltaToPSteps(tr, op.value, child, currPos) + // `lib0/delta.diff` produces short deltas that omit trailing + // retains, so the recursive call may exit before `currPos.i` + // reaches the child's close tag. Snap forward to the position right @@ -2839,7 +3000,7 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + for (const ins of bundle.inserts) { + if (delta.$insertOp.check(ins)) { + for (const n of ins.insert) { -+ newPChildren.push(deltaToPNode(n, schema, ins.format, attributedNodes)) ++ newPChildren.push(deltaToPNode(n, schema, ins.format)) + } + } else { // text op + newPChildren.push(schema.text(ins.insert, formattingAttributesToMarks(ins.format, schema))) @@ -2880,10 +3041,9 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + * @param {ProsemirrorDelta} d + * @param {import('prosemirror-model').Schema} schema + * @param {delta.FormattingAttributes|null} dformat -+ * @param {AttributedNodesPredicate} [attributedNodes] + * @return {Node} + */ -+export const deltaToPNode = (d, schema, dformat, attributedNodes = defaultAttributedNodes) => { ++export const deltaToPNode = (d, schema, dformat) => { + /** + * @type {Object} + */ @@ -2891,9 +3051,9 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + for (const attr of d.attrs) { + attrs[attr.key] = attr.value + } -+ 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 dc = d.children.map(c => delta.$insertOp.check(c) ? c.insert.map(cn => deltaToPNode(cn, schema, c.format)) : (delta.$textOp.check(c) ? [schema.text(c.insert, formattingAttributesToMarks(c.format, schema))] : [])) ++ const nodeName = d.name == null ? 'doc' : d.name ++ const nodeType = schema.nodes[nodeName] + if (!nodeType) { + throw new Error( + '[y/prosemirror]: node type does not exist in the schema: ' + d.name @@ -2901,13 +3061,8 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + } + const inputChildren = dc.flat(1) + const inputMarks = formattingAttributesToMarks(dformat, schema) -+ const finalAttrs = canonical !== nodeType.name -+ ? object.assign({ -+ 'y-attributed': true -+ }, attrs) -+ : attrs + const pNode = nodeType.createAndFill( -+ finalAttrs, ++ attrs, + inputChildren, + inputMarks + ) @@ -2928,7 +3083,7 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 +} + +/** -+ * @param {Transaction} tr ++ * @param {import('prosemirror-state').Transaction} tr + */ +export const trToDelta = (tr) => { + // const d = delta.create($prosemirrorDelta) @@ -2956,23 +3111,48 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + + const newEnd = afterDoc.resolve(step instanceof ReplaceAroundStep ? step.getMap().map(step.to) : step.from + step.slice.size) + -+ const oldBlockRange = oldStart.blockRange(oldEnd) -+ const newBlockRange = newStart.blockRange(newEnd) ++ let oldBlockRange = oldStart.blockRange(oldEnd) ++ let newBlockRange = newStart.blockRange(newEnd) ++ // A block split (from === to) makes the old positions share a leaf block, ++ // so blockRange returns a deeper range than the new positions which span ++ // across the split. Normalize to the shallower depth so the diff compares ++ // children of the same parent type. ++ if (oldBlockRange && newBlockRange && oldBlockRange.depth !== newBlockRange.depth) { ++ const minDepth = Math.min(oldBlockRange.depth, newBlockRange.depth) ++ oldBlockRange = new NodeRange(oldStart, oldEnd, minDepth) ++ newBlockRange = new NodeRange(newStart, newEnd, minDepth) ++ } + const oldDelta = deltaForBlockRange(oldBlockRange) + const newDelta = deltaForBlockRange(newBlockRange) + const diffD = delta.diff(oldDelta, newDelta) + const stepDelta = deltaModifyNodeAt(beforeDoc, oldBlockRange?.start || newBlockRange?.start || 0, d => { d.append(diffD) }) + return stepDelta + }) -+ .if(AddMarkStep, (step, { beforeDoc }) => -+ deltaModifyNodeAt(beforeDoc, step.from, d => { d.retain(step.to - step.from, marksToFormattingAttributes([step.mark])) }) -+ ) ++ .if(AddMarkStep, (step, { beforeDoc, afterDoc }) => { ++ const fromResolved = beforeDoc.resolve(step.from) ++ const toResolved = beforeDoc.resolve(step.to) ++ if (fromResolved.sameParent(toResolved)) { ++ return deltaModifyNodeAt(beforeDoc, step.from, d => { d.retain(step.to - step.from, marksToFormattingAttributes([step.mark])) }) ++ } ++ const oldBlockRange = fromResolved.blockRange(toResolved) ++ const newBlockRange = afterDoc.resolve(step.from).blockRange(afterDoc.resolve(step.to)) ++ const diffD = delta.diff(deltaForBlockRange(oldBlockRange), deltaForBlockRange(newBlockRange)) ++ return deltaModifyNodeAt(beforeDoc, oldBlockRange?.start || newBlockRange?.start || 0, d => { d.append(diffD) }) ++ }) ++ .if(RemoveMarkStep, (step, { beforeDoc, afterDoc }) => { ++ const fromResolved = beforeDoc.resolve(step.from) ++ const toResolved = beforeDoc.resolve(step.to) ++ if (fromResolved.sameParent(toResolved)) { ++ return deltaModifyNodeAt(beforeDoc, step.from, d => { d.retain(step.to - step.from, { [step.mark.type.name]: null }) }) ++ } ++ const oldBlockRange = fromResolved.blockRange(toResolved) ++ const newBlockRange = afterDoc.resolve(step.from).blockRange(afterDoc.resolve(step.to)) ++ const diffD = delta.diff(deltaForBlockRange(oldBlockRange), deltaForBlockRange(newBlockRange)) ++ return deltaModifyNodeAt(beforeDoc, oldBlockRange?.start || newBlockRange?.start || 0, d => { d.append(diffD) }) ++ }) + .if(AddNodeMarkStep, (step, { beforeDoc }) => + deltaModifyNodeAt(beforeDoc, step.pos, d => { d.retain(1, marksToFormattingAttributes([step.mark])) }) + ) -+ .if(RemoveMarkStep, (step, { beforeDoc }) => -+ deltaModifyNodeAt(beforeDoc, step.from, d => { d.retain(step.to - step.from, { [step.mark.type.name]: null }) }) -+ ) + .if(RemoveNodeMarkStep, (step, { beforeDoc }) => + deltaModifyNodeAt(beforeDoc, step.pos, d => { d.retain(1, { [step.mark.type.name]: null }) }) + ) @@ -2982,9 +3162,34 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + .if(DocAttrStep, step => + delta.create().setAttr(step.attr, step.value) + ) -+ .else(_step => { -+ // unknown step kind -+ error.unexpectedCase() ++ // @ts-ignore TS2589: tsc hits "excessively deep" expanding the recursive matcher ++ .else((step, { beforeDoc, afterDoc }) => { ++ const map = step.getMap() ++ let oldFrom = Infinity ++ let oldTo = 0 ++ map.forEach(/** @param {number} from @param {number} to @param {number} _newSize */ (from, to, _newSize) => { ++ oldFrom = math.min(oldFrom, from) ++ oldTo = math.max(oldTo, to) ++ }) ++ if (oldFrom === Infinity) { ++ return delta.create($prosemirrorDelta) ++ } ++ const mappedTo = map.map(oldTo) ++ const oldStart = beforeDoc.resolve(oldFrom) ++ const oldEnd = beforeDoc.resolve(oldTo) ++ const newStart = afterDoc.resolve(oldFrom) ++ const newEnd = afterDoc.resolve(mappedTo) ++ let oldBlockRange = oldStart.blockRange(oldEnd) ++ let newBlockRange = newStart.blockRange(newEnd) ++ if (oldBlockRange && newBlockRange && oldBlockRange.depth !== newBlockRange.depth) { ++ const minDepth = Math.min(oldBlockRange.depth, newBlockRange.depth) ++ oldBlockRange = new NodeRange(oldStart, oldEnd, minDepth) ++ newBlockRange = new NodeRange(newStart, newEnd, minDepth) ++ } ++ const oldDelta = deltaForBlockRange(oldBlockRange) ++ const newDelta = deltaForBlockRange(newBlockRange) ++ const diffD = delta.diff(oldDelta, newDelta) ++ return deltaModifyNodeAt(beforeDoc, oldBlockRange?.start || newBlockRange?.start || 0, d => { d.append(diffD) }) + }) + .done() + @@ -3049,8 +3254,11 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + path.push(resolvedOffset.index(d)) + } + -+ // add any offset into the parent node to the path -+ path.push(resolvedOffset.parentOffset) ++ if (resolvedOffset.parent.inlineContent) { ++ path.push(resolvedOffset.parentOffset) ++ } else { ++ path.push(resolvedOffset.index(depth)) ++ } + + return path +} @@ -3087,8 +3295,14 @@ index 0000000000000000000000000000000000000000..2234e5506a5341f39c80f389288823d8 + curNode = curNode.children[childIndex] + } + -+ // Last element is an offset within the current node -+ pmOffset += deltaPath[deltaPath.length - 1] ++ const lastEl = deltaPath[deltaPath.length - 1] ++ if (curNode.inlineContent) { ++ pmOffset += lastEl ++ } else { ++ for (let j = 0; j < lastEl; j++) { ++ pmOffset += curNode.children[j].nodeSize ++ } ++ } + + return pmOffset +} @@ -3360,6 +3574,418 @@ index 0000000000000000000000000000000000000000..70a7ae423be9bfd7a061984ce4ca74f4 diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index f62b6a1abc732b9c13eb83fd667534173706273d..0000000000000000000000000000000000000000 +diff --git a/src/y-attribution-to-diffset.js b/src/y-attribution-to-diffset.js +new file mode 100644 +index 0000000000000000000000000000000000000000..e2184c88bbc4fd72e3a1278d8adc726b8ba5c355 +--- /dev/null ++++ b/src/y-attribution-to-diffset.js +@@ -0,0 +1,406 @@ ++/** ++ * Convert a Yjs attributed delta into a DiffSet for decoration rendering. ++ * ++ * The attributed delta (from ytype.toDeltaDeep(am)) includes both current ++ * content and deleted content (retained because gc:false). This function ++ * walks the delta, maps each span onto the clean (displayed) PM document, ++ * and produces a Diff[] in the same contract that diff-decorations.js expects. ++ * ++ * Strategy B: the PM document mirrors the clean suggestion content (synced ++ * without attribution marks). This function reads the attribution separately ++ * and produces decorations without touching the document model. ++ */ ++import * as d from 'lib0/delta' ++import { Fragment } from 'prosemirror-model' ++import { formattingAttributesToMarks } from './sync-utils.js' ++ ++/** ++ * Who made a change and whether it was an insertion or deletion. ++ * ++ * @typedef {{ ++ * type: 'added' | 'removed', ++ * authorIds: string[], ++ * timestamp?: number ++ * }} Attribution ++ */ ++ ++/** ++ * The six diff types produced by `ydeltaToDiffSet`. ++ * ++ * @typedef {'inline-insert' | 'inline-delete' | 'block-insert' | 'block-delete' | 'inline-update' | 'block-update'} DiffType ++ */ ++ ++/** ++ * A single difference between the base and suggestion documents. ++ * ++ * `from`/`to` are positions in the *displayed* (clean) PM document. ++ * For delete types, `from === to` (zero-width) and `content` holds the ++ * removed fragment for ghost rendering. For update types, `attributes` ++ * holds the new values and `previousAttributes` (when available) the old. ++ * ++ * @typedef {{ ++ * type: DiffType, ++ * from: number, ++ * to: number, ++ * content?: import('prosemirror-model').Fragment, ++ * attribution: Attribution, ++ * attributes?: Record, ++ * previousAttributes?: Record ++ * }} Diff ++ */ ++ ++/** ++ * An ordered array of `Diff` objects covering all changes between the ++ * base and suggestion documents. ++ * ++ * @typedef {Diff[]} DiffSet ++ */ ++ ++/** ++ * Convert an attributed delta from `ytype.toDeltaDeep(am)` into a DiffSet ++ * whose positions are expressed in the clean (displayed) PM document. ++ * ++ * @param {d.DeltaAny} attributedDelta ++ * @param {{ displayedDoc: import('prosemirror-model').Node, schema: import('prosemirror-model').Schema }} opts ++ * @returns {DiffSet} ++ */ ++export const ydeltaToDiffSet = (attributedDelta, { displayedDoc, schema }) => { ++ /** @type {Diff[]} */ ++ const diffs = [] ++ const pos = { i: 0 } ++ walkBlockLevel(attributedDelta, pos, diffs, displayedDoc, schema) ++ return suppressSplitDeletes(diffs) ++} ++ ++/** ++ * Drop the spurious delete halves produced when a block is *split* in ++ * suggestion mode. ++ * ++ * A CRDT cannot move text: splitting "abc|def" into two blocks is recorded as ++ * "delete `def` from the first block" + "insert a new block whose content is ++ * `def`". `toDeltaDeep(am)` therefore reports `def` twice - once with ++ * `{delete}` at the tail of the original block, once with `{insert}` in the ++ * brand-new block - even though the clean displayed doc only shows it once (in ++ * the new block). Rendering the delete half draws a phantom strikethrough of ++ * text the user can already see (now green) in the inserted block right after ++ * it, and turns a trivial "add a paragraph below" into "delete the tail and ++ * re-insert it merged with the new text". ++ * ++ * Detection is structural: a delete diff (`inline-delete`/`block-delete`) ++ * immediately followed by a `block-insert` whose inserted content *contains* ++ * the deleted text as a contiguous substring is the source half of a split. ++ * The inserted block genuinely is new content (typed text plus the moved text), ++ * so we keep it as-is and only drop the redundant delete ghost. ++ * ++ * @param {Diff[]} diffs ++ * @returns {Diff[]} ++ */ ++function suppressSplitDeletes (diffs) { ++ /** @type {Set} */ ++ const drop = new Set() ++ for (let i = 0; i < diffs.length - 1; i++) { ++ const cur = diffs[i] ++ const next = diffs[i + 1] ++ if (cur.type !== 'inline-delete' && cur.type !== 'block-delete') continue ++ if (next.type !== 'block-insert') continue ++ // The moved-out block must sit right after the split point: only the ++ // source block's close token and the new block's open token (<= 2 PM ++ // positions) separate the delete anchor from the inserted block's start. ++ // This keeps the heuristic to genuine splits and avoids suppressing an ++ // unrelated delete that merely happens to share text with a later insert. ++ if (next.from - cur.from > 2) continue ++ const deletedText = fragmentText(cur.content) ++ if (deletedText.length === 0) continue ++ const insertedText = fragmentText(next.content) ++ if (insertedText.includes(deletedText)) { ++ drop.add(i) ++ } ++ } ++ if (drop.size === 0) return diffs ++ return diffs.filter((_, i) => !drop.has(i)) ++} ++ ++/** ++ * @param {import('prosemirror-model').Fragment | undefined} fragment ++ * @returns {string} ++ */ ++function fragmentText (fragment) { ++ if (!fragment || fragment.size === 0) return '' ++ return fragment.textBetween(0, fragment.size) ++} ++ ++/** ++ * Walk the children of a block-level delta (the doc delta or a container like ++ * blockquote). Each child is an insert op containing a sub-delta (block node). ++ * ++ * @param {d.DeltaAny} parentDelta ++ * @param {{ i: number }} pos - mutable PM position cursor ++ * @param {Diff[]} diffs ++ * @param {import('prosemirror-model').Node} displayedDoc ++ * @param {import('prosemirror-model').Schema} schema ++ */ ++function walkBlockLevel (parentDelta, pos, diffs, displayedDoc, schema) { ++ for (const op of parentDelta.children) { ++ if (d.$insertOp.check(op)) { ++ for (const child of op.insert) { ++ if (d.$deltaAny.check(child)) { ++ processBlockNode(child, op, pos, diffs, displayedDoc, schema) ++ } ++ } ++ } else if (d.$textOp.check(op)) { ++ processTextOp(op, pos, diffs, schema) ++ } ++ } ++} ++ ++/** ++ * Process a single block node from the attributed delta. ++ * ++ * @param {d.DeltaAny} nodeDelta - the block node's delta (paragraph, heading, etc.) ++ * @param {d.InsertOp} parentOp - the insert op that contains this node ++ * @param {{ i: number }} pos ++ * @param {Diff[]} diffs ++ * @param {import('prosemirror-model').Node} displayedDoc ++ * @param {import('prosemirror-model').Schema} schema ++ */ ++function processBlockNode (nodeDelta, parentOp, pos, diffs, displayedDoc, schema) { ++ const attr = parentOp.attribution ++ ++ if (attr?.delete) { ++ const content = reconstructNodeFragment(nodeDelta, schema) ++ diffs.push({ ++ type: 'block-delete', ++ from: pos.i, ++ to: pos.i, ++ content, ++ attribution: { type: 'removed', authorIds: attr.delete } ++ }) ++ return ++ } ++ ++ if (attr?.insert) { ++ const node = displayedDoc.nodeAt(pos.i) ++ if (node) { ++ diffs.push({ ++ type: 'block-insert', ++ from: pos.i, ++ to: pos.i + node.nodeSize, ++ content: Fragment.from(node), ++ attribution: { type: 'added', authorIds: attr.insert } ++ }) ++ pos.i += node.nodeSize ++ } ++ return ++ } ++ ++ if (attr?.format) { ++ const node = displayedDoc.nodeAt(pos.i) ++ if (node) { ++ const authorIds = uniqueAuthors(attr.format) ++ const formattedKeys = Object.keys(attr.format) ++ const allAttrs = extractDeltaAttrs(nodeDelta) ++ /** @type {Record} */ ++ const attributes = {} ++ for (const key of formattedKeys) { ++ attributes[key] = allAttrs[key] ++ } ++ diffs.push({ ++ type: 'block-update', ++ from: pos.i, ++ to: pos.i + node.nodeSize, ++ attribution: { type: 'added', authorIds }, ++ attributes, ++ previousAttributes: undefined ++ }) ++ } ++ } ++ ++ // Leaf block nodes (e.g. horizontal_rule) have no content and a fixed ++ // nodeSize of 1 - there is no separate close token to skip, so advancing ++ // by the open/close pair below would over-count by 1 and drift every ++ // subsequent position. Detect via the schema (not the document, which may ++ // transiently disagree with the attributed delta mid-edit) and advance by 1. ++ const nodeType = nodeDelta.name ? schema.nodes[nodeDelta.name] : null ++ if (nodeType?.isLeaf) { ++ pos.i += 1 ++ return ++ } ++ pos.i += 1 ++ walkInlineLevel(nodeDelta, pos, diffs, displayedDoc, schema) ++ pos.i += 1 ++} ++ ++/** ++ * Walk inline children of a block node delta. ++ * ++ * @param {d.DeltaAny} blockDelta ++ * @param {{ i: number }} pos ++ * @param {Diff[]} diffs ++ * @param {import('prosemirror-model').Node} displayedDoc ++ * @param {import('prosemirror-model').Schema} schema ++ */ ++function walkInlineLevel (blockDelta, pos, diffs, displayedDoc, schema) { ++ for (const op of blockDelta.children) { ++ if (d.$textOp.check(op)) { ++ processTextOp(op, pos, diffs, schema) ++ } else if (d.$insertOp.check(op)) { ++ for (const child of op.insert) { ++ if (d.$deltaAny.check(child)) { ++ processBlockNode(child, op, pos, diffs, displayedDoc, schema) ++ } ++ } ++ } ++ } ++} ++ ++/** ++ * Process a text op from the attributed delta. ++ * ++ * @param {d.TextOp} op ++ * @param {{ i: number }} pos ++ * @param {Diff[]} diffs ++ * @param {import('prosemirror-model').Schema} schema ++ */ ++function processTextOp (op, pos, diffs, schema) { ++ const text = op.insert ++ const len = text.length ++ const attr = op.attribution ++ ++ if (attr?.delete) { ++ const marks = safeMarks(op.format, schema) ++ diffs.push({ ++ type: 'inline-delete', ++ from: pos.i, ++ to: pos.i, ++ content: Fragment.from(schema.text(text, marks.length ? marks : undefined)), ++ attribution: { type: 'removed', authorIds: attr.delete } ++ }) ++ return ++ } ++ ++ if (attr?.insert) { ++ diffs.push({ ++ type: 'inline-insert', ++ from: pos.i, ++ to: pos.i + len, ++ attribution: { type: 'added', authorIds: attr.insert } ++ }) ++ pos.i += len ++ return ++ } ++ ++ if (attr?.format) { ++ const authorIds = uniqueAuthors(attr.format) ++ diffs.push({ ++ type: 'inline-update', ++ from: pos.i, ++ to: pos.i + len, ++ attribution: { type: 'added', authorIds }, ++ attributes: formatToDiffAttributes(op.format) ++ }) ++ pos.i += len ++ return ++ } ++ ++ pos.i += len ++} ++ ++/** ++ * Reconstruct a PM Fragment from a delta for ghost rendering. ++ * ++ * @param {d.DeltaAny} nodeDelta ++ * @param {import('prosemirror-model').Schema} schema ++ * @returns {Fragment} ++ */ ++function reconstructNodeFragment (nodeDelta, schema) { ++ const node = reconstructNode(nodeDelta, schema) ++ return node ? Fragment.from(node) : Fragment.empty ++} ++ ++/** ++ * @param {d.DeltaAny} nodeDelta ++ * @param {import('prosemirror-model').Schema} schema ++ * @returns {import('prosemirror-model').Node | null} ++ */ ++function reconstructNode (nodeDelta, schema) { ++ const nodeName = nodeDelta.name || 'paragraph' ++ const nodeType = schema.nodes[nodeName] ++ if (!nodeType) return null ++ ++ /** @type {Record} */ ++ const attrs = {} ++ for (const attr of nodeDelta.attrs) { ++ attrs[attr.key] = attr.value ++ } ++ ++ /** @type {import('prosemirror-model').Node[]} */ ++ const children = [] ++ for (const op of nodeDelta.children) { ++ if (d.$textOp.check(op)) { ++ const marks = safeMarks(op.format, schema) ++ children.push(schema.text(op.insert, marks.length ? marks : undefined)) ++ } else if (d.$insertOp.check(op)) { ++ for (const sub of op.insert) { ++ if (d.$deltaAny.check(sub)) { ++ const subNode = reconstructNode(sub, schema) ++ if (subNode) children.push(subNode) ++ } ++ } ++ } ++ } ++ ++ return nodeType.createAndFill(attrs, children) ++} ++ ++/** ++ * @param {Record | null | undefined} format ++ * @param {import('prosemirror-model').Schema} schema ++ * @returns {import('prosemirror-model').Mark[]} ++ */ ++function safeMarks (format, schema) { ++ if (!format) return [] ++ try { ++ return formattingAttributesToMarks(format, schema) ++ } catch { ++ return [] ++ } ++} ++ ++/** ++ * @param {Record} formatAttribution ++ * @returns {string[]} ++ */ ++function uniqueAuthors (formatAttribution) { ++ return [...new Set(Object.values(formatAttribution).flat())] ++} ++ ++/** ++ * @param {d.DeltaAny} nodeDelta ++ * @returns {Record} ++ */ ++function extractDeltaAttrs (nodeDelta) { ++ /** @type {Record} */ ++ const attrs = {} ++ for (const attr of nodeDelta.attrs) { ++ attrs[attr.key] = attr.value ++ } ++ return attrs ++} ++ ++/** ++ * Convert a delta format object to the Diff.attributes shape. ++ * The result captures which formatting keys changed (e.g. `{strong: {}, em: {}}`), ++ * stored under `format` so downstream code can display what changed. ++ * ++ * @param {Record | null | undefined} fmt ++ * @returns {{ format: Record } | undefined} ++ */ ++function formatToDiffAttributes (fmt) { ++ if (!fmt) return undefined ++ /** @type {Record} */ ++ const format = {} ++ for (const [k, v] of Object.entries(fmt)) { ++ format[k] = v ++ } ++ return { format } ++} diff --git a/src/y-prosemirror.js b/src/y-prosemirror.js deleted file mode 100644 index bb072b6e31a0184a56d7873dcae647f0d5711559..0000000000000000000000000000000000000000 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b940198106..ce117242f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ overrides: '@y/prosemirror>lib0': 1.0.0-rc.13 patchedDependencies: - '@y/prosemirror@2.0.0-2': 802334aa5d2410b9fc999d5fb5ffef01650a04e58b59fca24e142c55b3379728 + '@y/prosemirror@2.0.0-2': 31a04e3d011e8a286dfca9f0c8cc95119ee07e497420b9b58a2990945d3b842d '@y/y@14.0.0-rc.16': 4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9 lib0@1.0.0-rc.13: 328c50b547222f0e54a7a9f4f70926c0532c006dec4f24d6954635b8c3adb69e @@ -236,7 +236,7 @@ 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=802334aa5d2410b9fc999d5fb5ffef01650a04e58b59fca24e142c55b3379728)(@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) + version: 2.0.0-2(patch_hash=31a04e3d011e8a286dfca9f0c8cc95119ee07e497420b9b58a2990945d3b842d)(@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(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) @@ -4101,7 +4101,7 @@ importers: version: 9.1.1(react@19.2.5) '@y/prosemirror': specifier: ^2.0.0-2 - version: 2.0.0-2(patch_hash=802334aa5d2410b9fc999d5fb5ffef01650a04e58b59fca24e142c55b3379728)(@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) + version: 2.0.0-2(patch_hash=31a04e3d011e8a286dfca9f0c8cc95119ee07e497420b9b58a2990945d3b842d)(@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(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) @@ -4958,7 +4958,7 @@ importers: version: 3.22.4 '@y/prosemirror': specifier: ^2.0.0-2 - version: 2.0.0-2(patch_hash=802334aa5d2410b9fc999d5fb5ffef01650a04e58b59fca24e142c55b3379728)(@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) + version: 2.0.0-2(patch_hash=31a04e3d011e8a286dfca9f0c8cc95119ee07e497420b9b58a2990945d3b842d)(@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(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) @@ -6139,6 +6139,9 @@ importers: '@vitest/browser-playwright': specifier: 4.1.7 version: 4.1.7(msw@2.11.5(@types/node@20.19.37)(typescript@5.9.3))(playwright@1.51.1)(vite@8.0.8(@types/node@20.19.37)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.7) + '@y/prosemirror': + specifier: ^2.0.0-2 + version: 2.0.0-2(patch_hash=31a04e3d011e8a286dfca9f0c8cc95119ee07e497420b9b58a2990945d3b842d)(@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(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) @@ -23196,7 +23199,7 @@ snapshots: dependencies: '@types/node': 20.19.39 - '@y/prosemirror@2.0.0-2(patch_hash=802334aa5d2410b9fc999d5fb5ffef01650a04e58b59fca24e142c55b3379728)(@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/prosemirror@2.0.0-2(patch_hash=31a04e3d011e8a286dfca9f0c8cc95119ee07e497420b9b58a2990945d3b842d)(@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(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9)) '@y/y': 14.0.0-rc.16(patch_hash=4c87657af127f4fcf167b812054b3c34374c63cda90c4db9a831608c23b89df9) diff --git a/tests/package.json b/tests/package.json index a276b22593..60a5a3e4dd 100644 --- a/tests/package.json +++ b/tests/package.json @@ -31,6 +31,7 @@ "@vitest/browser-playwright": "4.1.7", "@y/protocols": "^1.0.6-rc.1", "@y/y": "^14.0.0-rc.16", + "@y/prosemirror": "^2.0.0-2", "eslint": "^8.57.1", "htmlfy": "^0.6.7", "react": "^19.2.5", diff --git a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-darwin.png index 49010b1ccd..f2df9ec38a 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-chromium-darwin.png index 271c80528c..398f5afb9f 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-chromium-darwin.png index f9d1756991..c1c52bab8a 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-all-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-all-chromium-darwin.png index 9b3be92b12..5f552c0e0b 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-all-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-all-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-chromium-darwin.png index 51b9bbeda5..efc7c596ab 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-chromium-darwin.png index 8fee64373e..940ccb30c0 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-chromium-darwin.png index 9875524537..e6c6df38f1 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-chromium-darwin.png index 2e8a6e78ba..dc840f0f58 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-chromium-darwin.png new file mode 100644 index 0000000000..dc1b4b683f Binary files /dev/null and b/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-chromium-darwin.png index baacc1234e..0a6a04b15a 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-chromium-darwin.png index 5e65d096b0..6a3345599c 100644 Binary files a/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-chromium-darwin.png and b/tests/src/browser/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-row-vs-column-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-row-vs-column-chromium-darwin.png new file mode 100644 index 0000000000..f5b8e0ed0b Binary files /dev/null and b/tests/src/browser/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-row-vs-column-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-chromium-darwin.png new file mode 100644 index 0000000000..93872e5799 Binary files /dev/null and b/tests/src/browser/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-chromium-darwin.png b/tests/src/browser/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-chromium-darwin.png new file mode 100644 index 0000000000..0eb454c873 Binary files /dev/null and b/tests/src/browser/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-chromium-darwin.png differ diff --git a/tests/src/browser/y-prosemirror/addRemoveBlocks.test.tsx b/tests/src/browser/y-prosemirror/addRemoveBlocks.test.tsx index 51398f7712..1d055d9927 100644 --- a/tests/src/browser/y-prosemirror/addRemoveBlocks.test.tsx +++ b/tests/src/browser/y-prosemirror/addRemoveBlocks.test.tsx @@ -25,7 +25,7 @@ const IMG_SRC = // changes (see `typeChanges.test.tsx`). The variant in // "add paragraph after existing block" below uses `insertBlocks` on a // non-empty doc and works fine. Marked `test.fails` until upstream. -test.fails("suggestion mode: add heading to empty doc", async () => { +test("suggestion mode: add heading to empty doc", async () => { const { editor, screen, baseDoc, suggestionDoc, sync } = await setupSuggestionTest({ userAction: "add heading at top" }); @@ -46,7 +46,7 @@ test.fails("suggestion mode: add heading to empty doc", async () => { expect(ydocXml(baseDoc)).toMatchInlineSnapshot(` " - + " @@ -62,9 +62,6 @@ test.fails("suggestion mode: add heading to empty doc", async () => { textColor="default" >New heading - - - " `); expect(editorHtml(editor)).toMatchInlineSnapshot(` @@ -77,12 +74,7 @@ test.fails("suggestion mode: add heading to empty doc", async () => { textAlignment="left" level="1" isToggleable="false" - > - New heading - - - - + >New heading " @@ -157,15 +149,9 @@ test("suggestion mode: add paragraph after existing block", async () => { isToggleable="false" >Title - - - - - Body text - - - - + + Body text + " `); @@ -246,11 +232,6 @@ test("suggestion mode: remove paragraph from heading+paragraph", async () => { isToggleable="false" >Title - - - Body text - - " `); @@ -297,9 +278,7 @@ test("suggestion mode: remove all blocks", async () => { " - - Only block - + " @@ -358,13 +337,6 @@ test("suggestion mode: delete nested block", async () => { Parent - - - - Child - - - " @@ -424,16 +396,7 @@ test("suggestion mode: delete parent block (with children)", async () => { " - - Parent - - - - - Child - - - + " @@ -451,7 +414,7 @@ test("suggestion mode: delete parent block (with children)", async () => { // content expression, so deltaToPSteps throws // `RangeError: Invalid content for node blockContainer`. Marked // `test.fails` until @y/y can represent deleting a sole atom block. -test.fails("suggestion mode: delete image block", async () => { +test("suggestion mode: delete image block", async () => { const { editor, sync } = await setupSuggestionTest({ userAction: "delete image", }); diff --git a/tests/src/browser/y-prosemirror/basicText.concurrent.test.tsx b/tests/src/browser/y-prosemirror/basicText.concurrent.test.tsx index f7be9b507c..44aad37308 100644 --- a/tests/src/browser/y-prosemirror/basicText.concurrent.test.tsx +++ b/tests/src/browser/y-prosemirror/basicText.concurrent.test.tsx @@ -128,12 +128,7 @@ test("concurrent: A fixes typo, B deletes the word", async () => { " - - hello - w - o - rold - + hello o " @@ -248,11 +243,9 @@ test("concurrent: A bolds the word, B italicises the word", async () => { hello - - - world - - + + world + diff --git a/tests/src/browser/y-prosemirror/basicText.test.tsx b/tests/src/browser/y-prosemirror/basicText.test.tsx index c40863b532..0caf732042 100644 --- a/tests/src/browser/y-prosemirror/basicText.test.tsx +++ b/tests/src/browser/y-prosemirror/basicText.test.tsx @@ -81,14 +81,7 @@ test("suggestion mode: 'hello world' -> 'hello universe'", async () => { " - - hello - wo - unive - r - ld - se - + hello universe " @@ -161,9 +154,7 @@ test("suggestion mode: add bold to 'world'", async () => { hello - - world - + world @@ -232,10 +223,7 @@ test("suggestion mode: remove bold from 'world'", async () => { " - - hello - world - + hello world " @@ -319,11 +307,9 @@ test("suggestion mode: add italic to already-bold 'world'", async () => { hello - - - world - - + + world + diff --git a/tests/src/browser/y-prosemirror/fixtures/suggestionFixture.tsx b/tests/src/browser/y-prosemirror/fixtures/suggestionFixture.tsx index babdfe0950..9dd89ad3d3 100644 --- a/tests/src/browser/y-prosemirror/fixtures/suggestionFixture.tsx +++ b/tests/src/browser/y-prosemirror/fixtures/suggestionFixture.tsx @@ -27,6 +27,7 @@ import { useCreateBlockNote } from "@blocknote/react"; import { Node as PMNode } from "@tiptap/pm/model"; import { Awareness } from "@y/protocols/awareness"; import * as Y from "@y/y"; +import { ySuggestionDecorationPluginKey } from "@y/prosemirror"; import { prettify } from "htmlfy"; import { expect } from "vitest"; import { page } from "vitest/browser"; @@ -142,7 +143,12 @@ export async function waitForSuggestion( editor: BlockNoteEditor, ): Promise { await expect - .poll(() => editor.prosemirrorState.doc.toString().includes("y-attributed")) + .poll( + () => + (ySuggestionDecorationPluginKey + .getState(editor.prosemirrorState) + ?.find().length || 0) > 0, + ) .toBe(true); } @@ -246,9 +252,7 @@ function opToXml(op: DeltaInsertOp): string { } /** Format a delta node's `attrs` map (e.g. block-level paragraph props). */ -function deltaAttrsToString( - attrs: DeltaJson["attrs"] | undefined, -): string { +function deltaAttrsToString(attrs: DeltaJson["attrs"] | undefined): string { if (attrs == null) { return ""; } diff --git a/tests/src/browser/y-prosemirror/moveBlocks.test.tsx b/tests/src/browser/y-prosemirror/moveBlocks.test.tsx index 22c8b61d06..49b768ffdd 100644 --- a/tests/src/browser/y-prosemirror/moveBlocks.test.tsx +++ b/tests/src/browser/y-prosemirror/moveBlocks.test.tsx @@ -69,23 +69,12 @@ test("suggestion mode: move paragraph up", async () => { expect(editorHtml(editor)).toMatchInlineSnapshot(` " - - - - - Middle - - - - + + Middle + First - - - Middle - - Last @@ -157,41 +146,17 @@ test("suggestion mode: move paragraph with children", async () => { expect(editorHtml(editor)).toMatchInlineSnapshot(` " - - - - - Parent - - - - - - - - - Child - - - - - - - - + + Parent + + + Child + + + First - - - Parent - - - Child - - - - " `); diff --git a/tests/src/browser/y-prosemirror/nesting.concurrent.test.tsx b/tests/src/browser/y-prosemirror/nesting.concurrent.test.tsx index ae2093ca1a..3f35bfdebb 100644 --- a/tests/src/browser/y-prosemirror/nesting.concurrent.test.tsx +++ b/tests/src/browser/y-prosemirror/nesting.concurrent.test.tsx @@ -129,34 +129,12 @@ test("concurrent: A indents N1, B indents N2 below N1", async () => { N0 - - - - - - - N1 - - - - - - + + + N1 + + - - - N1 - - - - - - - N2 - - - - " `); diff --git a/tests/src/browser/y-prosemirror/nesting.test.tsx b/tests/src/browser/y-prosemirror/nesting.test.tsx index 1fe3518a3d..72075524f4 100644 --- a/tests/src/browser/y-prosemirror/nesting.test.tsx +++ b/tests/src/browser/y-prosemirror/nesting.test.tsx @@ -78,25 +78,12 @@ test("suggestion mode: indent a block", async () => { N0 - - - - - - - N1 - - - - - - + + + N1 + + - - - N1 - - " `); @@ -158,23 +145,10 @@ test("suggestion mode: unindent a block", async () => { N0 - - - - N1 - - - - - - - - N1 - - - - + + N1 + " `); @@ -182,38 +156,82 @@ test("suggestion mode: unindent a block", async () => { // Change parent block's type while keeping its children. Hits the // known y-prosemirror type-change bug. -test.fails( - "suggestion mode: change block type of a block with children", - async () => { - const { editor, screen, baseDoc, suggestionDoc, sync } = - await setupSuggestionTest({ userAction: "parent → heading" }); - - editor.replaceBlocks(editor.document, [ - { - id: "n0", - type: "paragraph", - content: "N0", - children: [{ id: "n1", type: "paragraph", content: "N1" }], - }, - ]); - sync(); - await expect - .element(screen.getByTestId("editor-A").getByText("N0")) - .toBeVisible(); - - editor.getExtension(SuggestionsExtension)!.enableSuggestions(); - - const [parent] = editor.document; - editor.updateBlock(parent, { type: "heading", props: { level: 1 } }); - - await expect.poll(() => editor.document[0]?.type).toBe("heading"); - - await expect(screen.getByTestId("editor-root")).toMatchScreenshot( - "nesting-change-parent-type", - ); - - expect(ydocXml(baseDoc)).toMatchInlineSnapshot(); - expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(); - expect(editorHtml(editor)).toMatchInlineSnapshot(); - }, -); +test("suggestion mode: change block type of a block with children", async () => { + const { editor, screen, baseDoc, suggestionDoc, sync } = + await setupSuggestionTest({ userAction: "parent → heading" }); + + editor.replaceBlocks(editor.document, [ + { + id: "n0", + type: "paragraph", + content: "N0", + children: [{ id: "n1", type: "paragraph", content: "N1" }], + }, + ]); + sync(); + await expect + .element(screen.getByTestId("editor-A").getByText("N0")) + .toBeVisible(); + + editor.getExtension(SuggestionsExtension)!.enableSuggestions(); + + const [parent] = editor.document; + editor.updateBlock(parent, { type: "heading", props: { level: 1 } }); + + await expect.poll(() => editor.document[0]?.type).toBe("heading"); + + await expect(screen.getByTestId("editor-root")).toMatchScreenshot( + "nesting-change-parent-type", + ); + + expect(ydocXml(baseDoc)).toMatchInlineSnapshot(` + " + + N0 + + + N1 + + + + " + `); + expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(` + " + + N0 + + + N1 + + + + " + `); + expect(editorHtml(editor)).toMatchInlineSnapshot(` + " + + + N0 + + + N1 + + + + + " + `); +}); diff --git a/tests/src/browser/y-prosemirror/tables.concurrent.test.tsx b/tests/src/browser/y-prosemirror/tables.concurrent.test.tsx index 9abba3cc31..9899410cb1 100644 --- a/tests/src/browser/y-prosemirror/tables.concurrent.test.tsx +++ b/tests/src/browser/y-prosemirror/tables.concurrent.test.tsx @@ -28,7 +28,7 @@ const TABLE_2X2 = { // `applyChangesetToDelta: Unexpected case` in y-prosemirror when // these two suggestions sync, so this is marked `test.fails` until // upstream supports this interleaving. -test.fails("concurrent: A deletes a row, B adds a column", async () => { +test("concurrent: A deletes a row, B adds a column", async () => { const { userA, userB, @@ -82,11 +82,239 @@ test.fails("concurrent: A deletes a row, B adds a column", async () => { "table-concurrent-row-vs-column", ); - expect(ydocXml(baseDoc)).toMatchInlineSnapshot(); - expect(ydocXml(suggestionDocA)).toMatchInlineSnapshot(); - expect(ydocXml(suggestionDocB)).toMatchInlineSnapshot(); - expect(ydocXml(suggestionDocMerged)).toMatchInlineSnapshot(); - expect(editorHtml(merged.editor)).toMatchInlineSnapshot(); + expect(ydocXml(baseDoc)).toMatchInlineSnapshot(` + " + + + + + A1 + + + B1 + + + + + A2 + + + B2 + + +
+
+
" + `); + expect(ydocXml(suggestionDocA)).toMatchInlineSnapshot(` + " + + + + + A1 + + + B1 + + +
+
+
" + `); + expect(ydocXml(suggestionDocB)).toMatchInlineSnapshot(` + " + + + + + A1 + + + B1 + + + C1 + + + + + A2 + + + B2 + + + C2 + + +
+
+
" + `); + expect(ydocXml(suggestionDocMerged)).toMatchInlineSnapshot(` + " + + + + + A1 + + + B1 + + + C1 + + +
+
+
" + `); + expect(editorHtml(merged.editor)).toMatchInlineSnapshot(` + " + + + + + + A1 + + + B1 + + + C1 + + +
+
+
+
" + `); }); // Both users grow the table in independent directions: A adds a @@ -475,21 +703,15 @@ test("concurrent: A adds a row, B adds a column", async () => { > B1 - - - - - C1 - - - - + + C1 + { > B2 - - - - - C2 - - - - + + C2 + + + + + A3 + + + B3 + + + + - - - - - - - A3 - - - - - - - - - B3 - - - - - - - - - - - - - @@ -667,8 +865,28 @@ test("concurrent: A deletes a column, B adds a row", async () => { > B1 + + + + + + { > A1 + + + + + + { > B1 + + + + + + { > B3 + + + @@ -815,8 +1083,28 @@ test("concurrent: A deletes a column, B adds a row", async () => { > A1 + + + + + + { > A1 - - - B1 - - + + + + + + { > A2 - - - B2 - - - - - - - - - A3 - - - - - - - - - B3 - - - - - - + + + A3 + + + B3 + + @@ -1330,21 +1600,15 @@ test("concurrent: A adds a column, B adds a row", async () => { > B1 - - - - - C1 - - - - + + C1 + { > B2 - - - - - C2 - - - - + + C2 + + + + + A3 + + + B3 + + + + - - - - - - - A3 - - - - - - - - - B3 - - - - - - - - - - - - - diff --git a/tests/src/browser/y-prosemirror/tables.test.tsx b/tests/src/browser/y-prosemirror/tables.test.tsx index 723232be38..507c0ad5c2 100644 --- a/tests/src/browser/y-prosemirror/tables.test.tsx +++ b/tests/src/browser/y-prosemirror/tables.test.tsx @@ -232,40 +232,26 @@ test("suggestion mode: add row", async () => { B2 - - - - - - - A3 - - - - - - - - - B3 - - - - - - + + + A3 + + + B3 + + @@ -450,21 +436,15 @@ test("suggestion mode: add column", async () => { > B1 - - - - - C1 - - - - + + C1 + { > B2 - - - - - C2 - - - - + + C2 + @@ -640,28 +614,6 @@ test("suggestion mode: remove row", async () => { B1 - - - - A2 - - - B2 - - - @@ -793,17 +745,6 @@ test("suggestion mode: remove column", async () => { > A1 - - - B1 - - { > A2 - - - B2 - - @@ -980,10 +910,7 @@ test("suggestion mode: update text in cell", async () => { colspan="1" rowspan="1" > - - A1 - edited - + A1 edited { > B2 - - - @@ -1381,22 +1298,8 @@ test("suggestion mode: merge two cells", async () => { rowspan="1" colwidth="," > - - A1 - +B1 - + A1+B1 - - - B1 - - { > B2 - - - - - - - @@ -1591,26 +1481,17 @@ test("suggestion mode: split a merged cell", async () => { colspan="1" rowspan="1" > - - A1 - +B1 - + A1 + + + B1 - - - - - B1 - - - - { +test("suggestion mode: change list item to paragraph", async () => { const { editor, screen, baseDoc, suggestionDoc, sync } = await setupSuggestionTest({ userAction: "list → paragraph" }); @@ -50,13 +50,37 @@ test.fails("suggestion mode: change list item to paragraph", async () => { "type-change-list-to-paragraph", ); - expect(ydocXml(baseDoc)).toMatchInlineSnapshot(); - expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(); - expect(editorHtml(editor)).toMatchInlineSnapshot(); + expect(ydocXml(baseDoc)).toMatchInlineSnapshot(` + " + + hello world + + " + `); + expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(` + " + + hello world + + " + `); + expect(editorHtml(editor)).toMatchInlineSnapshot(` + " + + + hello world + + + " + `); }); // Promote a paragraph to a level-1 heading. Same inline content. -test.fails("suggestion mode: change paragraph to heading", async () => { +test("suggestion mode: change paragraph to heading", async () => { const { editor, screen, baseDoc, suggestionDoc, sync } = await setupSuggestionTest({ userAction: "paragraph → heading" }); @@ -79,7 +103,39 @@ test.fails("suggestion mode: change paragraph to heading", async () => { "type-change-paragraph-to-heading", ); - expect(ydocXml(baseDoc)).toMatchInlineSnapshot(); - expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(); - expect(editorHtml(editor)).toMatchInlineSnapshot(); + expect(ydocXml(baseDoc)).toMatchInlineSnapshot(` + " + + hello world + + " + `); + expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(` + " + + hello world + + " + `); + expect(editorHtml(editor)).toMatchInlineSnapshot(` + " + + + hello world + + + " + `); });