Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 347 additions & 0 deletions attribution-changes.md

Large diffs are not rendered by default.

229 changes: 123 additions & 106 deletions examples/01-basic/01-minimal/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,140 +1,157 @@
import "@blocknote/core/fonts/inter.css";
// Brings in BlockNote's styles INCLUDING the attribution rendering
// (insert = green, delete = red strike-through, format = amber, attributed
// variant blocks get an accent bar). See packages/core/src/editor/attribution.css.
import "@blocknote/core/style.css";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { acceptAllChanges, rejectAllChanges } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import * as Y from "@y/y";
import { Awareness } from "@y/protocols/awareness";
import * as Y from "@y/y";
import { useRef } from "react";

/**
* Attribution demo.
*
* - `doc` is the shared, committed document. "Client A" and "Client B" bind to
* the SAME Y.Doc, so they collaborate in real time (no provider needed).
* - `viewDoc` / `suggestionDoc` are derived from `doc` via
* `DiffAttributionManager`s. Their AMs are created while every doc is still
* empty, so the base document forwards into them as *committed* (un-attributed)
* content - and they never independently `createAndFill` (which would merge to
* two blockGroups, invalid for BlockNote's `doc: "blockGroup"`).
* - "Suggestion Mode": edits here stay as suggestions (suggestionMode = true).
* - "Review": sees the suggestions (suggestionMode = false). Accepting commits
* them into the base document for everyone; rejecting discards them.
*/
const doc = new Y.Doc();
const provider = {
awareness: new Awareness(doc),
};

const doc2 = new Y.Doc();
const provider2 = {
awareness: new Awareness(doc2),
};

const suggestingDoc = new Y.Doc({ isSuggestionDoc: true });
const suggestingProvider = {
awareness: new Awareness(suggestingDoc),
};
const suggestingAttributionManager = Y.createAttributionManagerFromDiff(
doc,
suggestingDoc,
{
attrs: [Y.createAttributionItem("insert", ["nickthesick"])],
},
);
suggestingAttributionManager.suggestionMode = false;
const awarenessA = new Awareness(doc);
const awarenessB = new Awareness(doc);

const suggestionModeDoc = new Y.Doc({ isSuggestionDoc: true });
const suggestionModeProvider = {
awareness: new Awareness(suggestionModeDoc),
};
const suggestionModeAttributionManager = Y.createAttributionManagerFromDiff(
doc,
suggestionModeDoc,
{ attrs: [Y.createAttributionItem("insert", ["nickthesick"])] },
);
suggestionModeAttributionManager.suggestionMode = true;

// Function to sync two documents
function syncDocs(sourceDoc: Y.Doc, targetDoc: Y.Doc) {
// Create update message from source
const update = Y.encodeStateAsUpdate(sourceDoc);
const attrs = new Y.Attributions();

// Apply update to target
Y.applyUpdate(targetDoc, update);
}
const viewDoc = new Y.Doc({ isSuggestionDoc: true });
const viewAwareness = new Awareness(viewDoc);
const viewAM = Y.createAttributionManagerFromDiff(doc, viewDoc, { attrs });
viewAM.suggestionMode = false;

// Set up two-way sync
function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) {
// Sync initial states
syncDocs(doc1, doc2);
syncDocs(doc2, doc1);
const suggestionDoc = new Y.Doc({ isSuggestionDoc: true });
const suggestionAwareness = new Awareness(suggestionDoc);
const suggestionAM = Y.createAttributionManagerFromDiff(doc, suggestionDoc, {
attrs,
});
suggestionAM.suggestionMode = true;

// Set up observers for future changes
doc1.on("update", (update: Uint8Array) => {
Y.applyUpdate(doc2, update);
});

doc2.on("update", (update: Uint8Array) => {
Y.applyUpdate(doc1, update);
});
// Keep the two suggestion documents in sync so "Review" sees what "Suggestion
// Mode" produces (and vice-versa).
function syncTwoWay(a: Y.Doc, b: Y.Doc) {
Y.applyUpdate(b, Y.encodeStateAsUpdate(a));
Y.applyUpdate(a, Y.encodeStateAsUpdate(b));
a.on("update", (u: Uint8Array) => Y.applyUpdate(b, u));
b.on("update", (u: Uint8Array) => Y.applyUpdate(a, u));
}
syncTwoWay(viewDoc, suggestionDoc);

setupTwoWaySync(doc, doc2);

setupTwoWaySync(suggestingDoc, suggestionModeDoc);

function Editor({
fragment,
provider,
attributionManager,
}: {
fragment: Y.XmlFragment;
provider: { awareness: Awareness };
function Editor(props: {
fragment: Y.Type;
awareness: Awareness;
attributionManager?: Y.AbstractAttributionManager;
user: { name: string; color: string };
editorRef?: React.MutableRefObject<any>;
}) {
const editor = useCreateBlockNote({
collaboration: {
fragment,
provider,
user: {
name: "Hello",
color: "#FFFFFF",
},
attributionManager,
fragment: props.fragment as any,
provider: { awareness: props.awareness },
user: props.user,
attributionManager: props.attributionManager,
},
});

if (props.editorRef) {
props.editorRef.current = editor;
}
return <BlockNoteView editor={editor} />;
}

const panel: React.CSSProperties = {
flex: 1,
border: "1px solid #e0e0e0",
borderRadius: 8,
padding: 8,
minWidth: 0,
};
const heading: React.CSSProperties = {
fontFamily: "sans-serif",
fontSize: 13,
fontWeight: 600,
marginBottom: 6,
display: "flex",
alignItems: "center",
gap: 8,
};

export default function App() {
// Renders the editor instance using a React component.
const reviewRef = useRef<any>(null);

const run = (cmd: typeof acceptAllChanges) => () => {
const view = reviewRef.current?.prosemirrorView;
if (view) {
cmd()(view.state, (tr: any) => view.dispatch(tr));
}
};

return (
<div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "10px",
margin: "10px",
}}
>
<div style={{ flex: 1 }}>
Client A
<Editor fragment={doc.getXmlFragment("doc")} provider={provider} />
<div style={{ maxWidth: 1100, margin: "16px auto", fontFamily: "sans-serif" }}>
<h2>BlockNote attribution / suggestion mode</h2>
<p style={{ color: "#555", fontSize: 14 }}>
Type in <b>Suggestion Mode</b> - your edits show up as tracked
suggestions (green = inserted, red strike-through = deleted) in{" "}
<b>Review</b>. Click <b>Accept all</b> to merge them into the shared
document (Client A &amp; B), or <b>Reject all</b> to discard them.
</p>

<div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<div style={panel}>
<div style={heading}>Client A (shared document)</div>
<Editor
fragment={doc.get("doc")}
awareness={awarenessA}
user={{ name: "Alice", color: "#2e86de" }}
/>
</div>
<div style={{ flex: 1 }}>
Client B
<Editor fragment={doc2.getXmlFragment("doc")} provider={provider2} />
<div style={panel}>
<div style={heading}>Client B (shared document)</div>
<Editor
fragment={doc.get("doc")}
awareness={awarenessB}
user={{ name: "Bob", color: "#8e44ad" }}
/>
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "10px",
margin: "10px",
}}
>
<div style={{ flex: 1 }}>
View Suggestions Mode

<div style={{ display: "flex", gap: 12 }}>
<div style={panel}>
<div style={heading}>
Review (view suggestions)
<button onClick={run(acceptAllChanges)}>Accept all</button>
<button onClick={run(rejectAllChanges)}>Reject all</button>
</div>
<Editor
fragment={suggestingDoc.getXmlFragment("doc")}
provider={suggestingProvider}
attributionManager={suggestingAttributionManager}
fragment={viewDoc.get("doc")}
awareness={viewAwareness}
attributionManager={viewAM}
user={{ name: "Reviewer", color: "#16a085" }}
editorRef={reviewRef}
/>
</div>
<div style={{ flex: 1 }}>
Suggestion Mode
<div style={panel}>
<div style={heading}>Suggestion Mode (your edits become suggestions)</div>
<Editor
fragment={suggestionModeDoc.getXmlFragment("doc")}
provider={suggestionModeProvider}
attributionManager={suggestionModeAttributionManager}
fragment={suggestionDoc.get("doc")}
awareness={suggestionAwareness}
attributionManager={suggestionAM}
user={{ name: "Suggester", color: "#e67e22" }}
/>
</div>
</div>
Expand Down
13 changes: 10 additions & 3 deletions examples/01-basic/01-minimal/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,25 @@ export default defineConfig((conf) => ({
resolve: {
alias:
conf.command === "build" ||
!fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
!fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// Comment out the lines below to load a built version of blocknote
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
"../../packages/core/src/"
"../../../packages/core/src/"
),
"@blocknote/react": path.resolve(
__dirname,
"../../packages/react/src/"
"../../../packages/react/src/"
),
// mantine pulls in @blocknote/core too; alias it to src so the demo
// doesn't load a stale built packages/core/dist alongside the live
// source (dual-package hazard that loaded the pre-fix getBlockFromPos).
"@blocknote/mantine": path.resolve(
__dirname,
"../../../packages/mantine/src/"
),
} as any),
},
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@
"overrides": {
"@headlessui/react": "^2.2.4",
"@tiptap/core": "^3.0.0",
"@tiptap/pm": "^3.0.0"
},
"patchedDependencies": {
"@y/prosemirror": "patches/@y__prosemirror.patch"
"@tiptap/pm": "^3.0.0",
"lib0": "file:./vendor/lib0-1.0.0-rc.14.tgz",
"@y/y": "file:./vendor/y-y-14.0.0-rc.17.tgz",
"@y/prosemirror": "file:./vendor/y-prosemirror-2.0.0-4.tgz"
}
},
"packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b",
Expand Down
38 changes: 27 additions & 11 deletions packages/core/src/api/getBlockInfoFromPos.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Node, ResolvedPos } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import {
canonicalBlockName,
isDeletedNode,
} from "../schema/blocks/attributedNodes.js";

type SingleBlockInfo = {
node: Node;
Expand Down Expand Up @@ -141,21 +145,30 @@ export function getBlockInfoWithManualOffset(
};

if (bnBlockNode.type.name === "blockContainer") {
// A blockContainer historically held exactly one blockContent. Under
// attribution it can transiently hold several (a deleted `*--attributed`
// variant next to the live one). Pick the block's CURRENT content - the
// first non-deleted blockContent - falling back to the first blockContent if
// the whole block is a pending deletion. (This is the position-aware twin of
// `getBlockNode`.)
let blockContent: SingleBlockInfo | undefined;
let blockContentFallback: SingleBlockInfo | undefined;
let blockGroup: SingleBlockInfo | undefined;

bnBlockNode.forEach((node, offset) => {
if (node.type.spec.group === "blockContent") {
// console.log(beforePos, offset);
const blockContentNode = node;
// `isInGroup` (not `spec.group === "blockContent"`) so attributed variants,
// whose group is `"blockContent attributed"`, are recognized too.
if (node.type.isInGroup("blockContent")) {
const blockContentBeforePos = bnBlockBeforePos + offset + 1;
const blockContentAfterPos = blockContentBeforePos + node.nodeSize;

blockContent = {
node: blockContentNode,
const info: SingleBlockInfo = {
node,
beforePos: blockContentBeforePos,
afterPos: blockContentAfterPos,
afterPos: blockContentBeforePos + node.nodeSize,
};
blockContentFallback = blockContentFallback ?? info;
if (!isDeletedNode(node)) {
blockContent = blockContent ?? info;
}
} else if (node.type.name === "blockGroup") {
const blockGroupNode = node;
const blockGroupBeforePos = bnBlockBeforePos + offset + 1;
Expand All @@ -169,7 +182,8 @@ export function getBlockInfoWithManualOffset(
}
});

if (!blockContent) {
const chosenBlockContent = blockContent ?? blockContentFallback;
if (!chosenBlockContent) {
throw new Error(
`blockContainer node does not contain a blockContent node in its children: ${bnBlockNode}`,
);
Expand All @@ -178,9 +192,11 @@ export function getBlockInfoWithManualOffset(
return {
isBlockContainer: true,
bnBlock,
blockContent,
blockContent: chosenBlockContent,
childContainer: blockGroup,
blockNoteType: blockContent.node.type.name,
// Strip the `--attributed` suffix so the block API reports the canonical
// block type even while a suggested variant is rendered in the live doc.
blockNoteType: canonicalBlockName(chosenBlockContent.node.type.name),
};
} else {
if (!bnBlock.node.type.isInGroup("childContainer")) {
Expand Down
Loading