diff --git a/examples/03-ui-components/17-advanced-tables-2/.bnexample.json b/examples/03-ui-components/17-advanced-tables-2/.bnexample.json
new file mode 100644
index 0000000000..52ae0e6e39
--- /dev/null
+++ b/examples/03-ui-components/17-advanced-tables-2/.bnexample.json
@@ -0,0 +1,11 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "must",
+ "tags": [
+ "Intermediate",
+ "UI Components",
+ "Tables",
+ "Appearance & Styling"
+ ]
+}
\ No newline at end of file
diff --git a/examples/03-ui-components/17-advanced-tables-2/README.md b/examples/03-ui-components/17-advanced-tables-2/README.md
new file mode 100644
index 0000000000..827e0c1428
--- /dev/null
+++ b/examples/03-ui-components/17-advanced-tables-2/README.md
@@ -0,0 +1,54 @@
+# Advanced Tables with Calculated Columns
+
+This example demonstrates advanced table features including automatic calculations. It shows how to create a table with calculated columns that automatically update when values change.
+
+## Features
+
+- **Automatic Calculations**: Quantity × Price = Total for each row
+- **Grand Total**: Automatically calculated sum of all totals
+- **Real-time Updates**: Calculations update immediately when you change quantity or price values
+- **Split cells**: Merge and split table cells
+- **Cell background color**: Color individual cells
+- **Cell text color**: Change text color in cells
+- **Table row and column headers**: Use headers for better organization
+
+## How It Works
+
+The example uses the `onChange` event listener to detect when table content changes. When a table is updated, it automatically:
+
+1. Extracts quantity and price values from each data row
+2. Calculates the total (quantity × price) for each row
+3. Updates the total column with the calculated values
+4. Calculates and updates the grand total
+
+## Code Highlights
+
+```tsx
+ {
+ const changes = getChanges();
+
+ changes.forEach((change) => {
+ if (change.type === "update" && change.block.type === "table") {
+ const updatedRows = calculateTableTotals(change.block);
+ if (updatedRows) {
+ editor.updateBlock(change.block, {
+ type: "table",
+ content: {
+ ...change.block.content,
+ rows: updatedRows as any,
+ } as any,
+ });
+ }
+ }
+ });
+ }}
+>
+```
+
+**Relevant Docs:**
+
+- [Tables](/docs/features/blocks/tables)
+- [Editor Setup](/docs/getting-started/editor-setup)
+- [Events](/docs/reference/editor/events)
diff --git a/examples/03-ui-components/17-advanced-tables-2/index.html b/examples/03-ui-components/17-advanced-tables-2/index.html
new file mode 100644
index 0000000000..8c97f71cc4
--- /dev/null
+++ b/examples/03-ui-components/17-advanced-tables-2/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Advanced Tables with Calculated Columns
+
+
+
+
+
+
+
diff --git a/examples/03-ui-components/17-advanced-tables-2/main.tsx b/examples/03-ui-components/17-advanced-tables-2/main.tsx
new file mode 100644
index 0000000000..677c7f7eed
--- /dev/null
+++ b/examples/03-ui-components/17-advanced-tables-2/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+
+);
diff --git a/examples/03-ui-components/17-advanced-tables-2/package.json b/examples/03-ui-components/17-advanced-tables-2/package.json
new file mode 100644
index 0000000000..7da6d78b42
--- /dev/null
+++ b/examples/03-ui-components/17-advanced-tables-2/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@blocknote/example-ui-components-advanced-tables-2",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@blocknote/core": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/ariakit": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/shadcn": "latest",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.0",
+ "@types/react-dom": "^19.1.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "vite": "^5.3.4"
+ }
+}
\ No newline at end of file
diff --git a/examples/03-ui-components/17-advanced-tables-2/src/App.tsx b/examples/03-ui-components/17-advanced-tables-2/src/App.tsx
new file mode 100644
index 0000000000..7d5f6fe531
--- /dev/null
+++ b/examples/03-ui-components/17-advanced-tables-2/src/App.tsx
@@ -0,0 +1,677 @@
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import { useCreateBlockNote } from "@blocknote/react";
+import type { Block, DefaultBlockSchema } from "@blocknote/core";
+import { useRef } from "react";
+
+export default function App() {
+ const applying = useRef(false);
+
+ // Creates a new editor instance.
+ const editor = useCreateBlockNote({
+ // This enables the advanced table features
+ tables: {
+ splitCells: true,
+ cellBackgroundColor: true,
+ cellTextColor: true,
+ headers: true,
+ },
+ initialContent: [
+ {
+ id: "7e498b3d-d42e-4ade-9be0-054b292715ea",
+ type: "heading",
+ props: {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ level: 2,
+ },
+ content: [
+ {
+ type: "text",
+ text: "Advanced Tables with Calculated Columns",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "cbf287c6-770b-413a-bff5-ad490a0b562a",
+ type: "table",
+ props: {
+ textColor: "default",
+ },
+ content: {
+ type: "tableContent",
+ columnWidths: [150, 120, 120, 120],
+ headerRows: 1,
+ rows: [
+ {
+ cells: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Item",
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "gray",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Quantity",
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "gray",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Price ($)",
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "gray",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Total ($)",
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "blue",
+ textColor: "white",
+ textAlignment: "center",
+ },
+ },
+ ],
+ },
+ {
+ cells: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Laptop",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "2",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "1200",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "2400",
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "green",
+ textColor: "white",
+ textAlignment: "center",
+ },
+ },
+ ],
+ },
+ {
+ cells: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Mouse",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "5",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "25",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "125",
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "green",
+ textColor: "white",
+ textAlignment: "center",
+ },
+ },
+ ],
+ },
+ {
+ cells: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Keyboard",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "left",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "3",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "80",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "default",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "240",
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "green",
+ textColor: "white",
+ textAlignment: "center",
+ },
+ },
+ ],
+ },
+ {
+ cells: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "Grand Total",
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "yellow",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "yellow",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "",
+ styles: {},
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "yellow",
+ textColor: "default",
+ textAlignment: "center",
+ },
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: "2765",
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "red",
+ textColor: "white",
+ textAlignment: "center",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ children: [],
+ },
+ {
+ id: "16e76a94-74e5-42e2-b461-fc9da9f381f7",
+ type: "paragraph",
+ props: {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Features:",
+ styles: {},
+ },
+ ],
+ children: [
+ {
+ id: "785fc9f7-8554-47f4-a4df-8fe2f1438cac",
+ type: "bulletListItem",
+ props: {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Automatic calculation of totals (Quantity × Price)",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "1d0adf08-1b42-421a-b9ea-b3125dcc96d9",
+ type: "bulletListItem",
+ props: {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Grand total calculation",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "99991aa7-9d86-4d06-9073-b1a9c0329062",
+ type: "bulletListItem",
+ props: {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Cell background & foreground coloring",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "c7bf2a7c-8972-44f1-acd8-cf60fa734068",
+ type: "bulletListItem",
+ props: {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Splitting & merging cells",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ {
+ id: "785fc9f7-8554-47f4-a4df-8fe2f1438cac",
+ type: "bulletListItem",
+ props: {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ },
+ content: [
+ {
+ type: "text",
+ text: "Header rows & columns",
+ styles: {},
+ },
+ ],
+ children: [],
+ },
+ ],
+ },
+ {
+ id: "c7bf2a7c-8972-44f1-acd8-cf60fa734068",
+ type: "paragraph",
+ props: {
+ textColor: "default",
+ backgroundColor: "default",
+ textAlignment: "left",
+ },
+ content: [],
+ children: [],
+ },
+ ],
+ });
+
+ // Function to calculate totals for a table
+ const calculateTableTotals = (tableBlock: Block) => {
+ if (tableBlock.type !== "table") return;
+
+ const rows = tableBlock.content.rows;
+ if (rows.length < 2) return; // Need at least header + 1 data row
+
+ let grandTotal = 0;
+ const updatedRows = rows.map((row, rowIndex: number) => {
+ if (rowIndex === 0) return row; // Skip header row
+ if (rowIndex === rows.length - 1) return row; // Skip grand total row
+
+ // Helper function to extract text from a cell
+ const getCellText = (cell: any): string => {
+ if (typeof cell === "string") return cell;
+ if (cell && typeof cell === "object" && "content" in cell) {
+ return cell.content?.[0]?.text || "0";
+ }
+ return "0";
+ };
+
+ const itemText = getCellText(row.cells[0]);
+ const quantityText = getCellText(row.cells[1]);
+ const priceText = getCellText(row.cells[2]);
+
+ const quantity = parseFloat(quantityText) || 0;
+ const price = parseFloat(priceText) || 0;
+ const total = quantity * price;
+
+ grandTotal += total;
+
+ // Update the total cell
+ const updatedCells = [...row.cells];
+ updatedCells[3] = {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: total.toString(),
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "green",
+ textColor: "white",
+ textAlignment: "center",
+ },
+ };
+
+ // Update item label if total is above 4k
+ const baseItemText = itemText.replace(" (eligible for discount)", "");
+ if (total >= 4000) {
+ updatedCells[0] = {
+ ...row.cells[0],
+ content: [
+ {
+ type: "text",
+ text: baseItemText + " (eligible for discount)",
+ styles: {},
+ },
+ ],
+ };
+ } else {
+ updatedCells[0] = {
+ ...row.cells[0],
+ content: [
+ {
+ type: "text",
+ text: baseItemText,
+ styles: {},
+ },
+ ],
+ };
+ }
+
+ return {
+ ...row,
+ cells: updatedCells,
+ };
+ });
+
+ // Update grand total row
+ const grandTotalRow = updatedRows[rows.length - 1];
+ if (grandTotalRow) {
+ const updatedGrandTotalCells = [...grandTotalRow.cells];
+ updatedGrandTotalCells[3] = {
+ type: "tableCell",
+ content: [
+ {
+ type: "text",
+ text: grandTotal.toString(),
+ styles: { bold: true },
+ },
+ ],
+ props: {
+ colspan: 1,
+ rowspan: 1,
+ backgroundColor: "red",
+ textColor: "white",
+ textAlignment: "center",
+ },
+ };
+
+ updatedRows[rows.length - 1] = {
+ ...grandTotalRow,
+ cells: updatedGrandTotalCells,
+ };
+ }
+
+ return updatedRows as typeof tableBlock.content.rows;
+ };
+
+ // Renders the editor instance using a React component.
+ return (
+ {
+ const changes = getChanges();
+
+ if (changes.length === 0 || applying.current) return;
+
+ // prevents a double onChange because we're updating the block here
+ applying.current = true;
+
+ changes.forEach((change) => {
+ if (change.type === "update" && change.block.type === "table") {
+ const updatedRows = calculateTableTotals(change.block);
+ if (updatedRows) {
+ // Use any type to bypass complex type checking for this demo
+ editor.updateBlock(change.block, {
+ type: "table",
+ content: {
+ ...change.block.content,
+ rows: updatedRows,
+ },
+ });
+ }
+ }
+ });
+
+ requestAnimationFrame(() => (applying.current = false));
+ }}
+ >
+ );
+}
diff --git a/examples/03-ui-components/17-advanced-tables-2/tsconfig.json b/examples/03-ui-components/17-advanced-tables-2/tsconfig.json
new file mode 100644
index 0000000000..dbe3e6f62d
--- /dev/null
+++ b/examples/03-ui-components/17-advanced-tables-2/tsconfig.json
@@ -0,0 +1,36 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ESNext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true
+ },
+ "include": [
+ "."
+ ],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/examples/03-ui-components/17-advanced-tables-2/vite.config.ts b/examples/03-ui-components/17-advanced-tables-2/vite.config.ts
new file mode 100644
index 0000000000..f62ab20bc2
--- /dev/null
+++ b/examples/03-ui-components/17-advanced-tables-2/vite.config.ts
@@ -0,0 +1,32 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite";
+// import eslintPlugin from "vite-plugin-eslint";
+// https://vitejs.dev/config/
+export default defineConfig((conf) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !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/"
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../packages/react/src/"
+ ),
+ } as any),
+ },
+}));
diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
index 663d63a542..f4e99a572a 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
@@ -4,7 +4,7 @@ import {
type Node as PMNode,
Slice,
} from "prosemirror-model";
-import type { Transaction } from "prosemirror-state";
+import { TextSelection, Transaction } from "prosemirror-state";
import { ReplaceStep, Transform } from "prosemirror-transform";
import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
@@ -27,6 +27,7 @@ import {
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../../nodeUtil.js";
import { getPmSchema } from "../../../pmUtil.js";
+import { TableMap } from "prosemirror-tables";
// for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface
export const updateBlockCommand = <
@@ -56,7 +57,7 @@ export function updateBlockTr<
I extends InlineContentSchema,
S extends StyleSchema,
>(
- tr: Transform,
+ tr: Transaction,
posBeforeBlock: number,
block: PartialBlock,
replaceFromPos?: number,
@@ -64,6 +65,11 @@ export function updateBlockTr<
) {
const blockInfo = getBlockInfoFromResolvedPos(tr.doc.resolve(posBeforeBlock));
+ let cellAnchor: CellAnchor | null = null;
+ if (blockInfo.blockNoteType === "table") {
+ cellAnchor = captureCellAnchor(tr);
+ }
+
const pmSchema = getPmSchema(tr);
if (
@@ -143,6 +149,10 @@ export function updateBlockTr<
...blockInfo.bnBlock.node.attrs,
...block.props,
});
+
+ if (cellAnchor) {
+ restoreCellAnchor(tr, blockInfo, cellAnchor);
+ }
}
function updateBlockContentNode<
@@ -329,3 +339,105 @@ export function updateBlock<
const pmSchema = getPmSchema(tr);
return nodeToBlock(blockContainerNode, pmSchema);
}
+
+type CellAnchor = { row: number; col: number; offset: number };
+
+/**
+ * Captures the cell anchor from the current selection.
+ * @param tr - The transaction to capture the cell anchor from.
+ *
+ * @returns The cell anchor, or null if no cell is selected.
+ */
+export function captureCellAnchor(tr: Transaction): CellAnchor | null {
+ const sel = tr.selection;
+ if (!(sel instanceof TextSelection)) return null;
+
+ const $head = tr.doc.resolve(sel.head);
+ // Find enclosing cell and table
+ let cellDepth = -1;
+ let tableDepth = -1;
+ for (let d = $head.depth; d >= 0; d--) {
+ const name = $head.node(d).type.name;
+ if (cellDepth < 0 && (name === "tableCell" || name === "tableHeader")) {
+ cellDepth = d;
+ }
+ if (name === "table") {
+ tableDepth = d;
+ break;
+ }
+ }
+ if (cellDepth < 0 || tableDepth < 0) return null;
+
+ // Absolute positions (before the cell)
+ const cellPos = $head.before(cellDepth);
+ const tablePos = $head.before(tableDepth);
+ const table = tr.doc.nodeAt(tablePos);
+ if (!table || table.type.name !== "table") return null;
+
+ // Visual grid position via TableMap (handles spans)
+ const map = TableMap.get(table);
+ const rel = cellPos - (tablePos + 1); // relative to inside table
+ const idx = map.map.indexOf(rel);
+ if (idx < 0) return null;
+
+ const row = Math.floor(idx / map.width);
+ const col = idx % map.width;
+
+ // Caret offset relative to the start of paragraph text
+ const paraPos = cellPos + 1; // pos BEFORE tableParagraph
+ const textStart = paraPos + 1; // start of paragraph text
+ const offset = Math.max(0, sel.head - textStart);
+
+ return { row, col, offset };
+}
+
+function restoreCellAnchor(
+ tr: Transaction,
+ blockInfo: BlockInfo,
+ a: CellAnchor,
+): boolean {
+ if (blockInfo.blockNoteType !== "table") return false;
+
+ // 1) Resolve the table node in the current document
+ let tablePos = -1;
+
+ if (blockInfo.isBlockContainer) {
+ // Prefer the blockContent position when available (points directly at the PM table node)
+ tablePos = tr.mapping.map(blockInfo.blockContent.beforePos);
+ } else {
+ // Fallback: scan within the mapped bnBlock range to find the inner table node
+ const start = tr.mapping.map(blockInfo.bnBlock.beforePos);
+ const end = start + (tr.doc.nodeAt(start)?.nodeSize || 0);
+ tr.doc.nodesBetween(start, end, (node, pos) => {
+ if (node.type.name === "table") {
+ tablePos = pos;
+ return false;
+ }
+ return true;
+ });
+ }
+
+ const table = tablePos >= 0 ? tr.doc.nodeAt(tablePos) : null;
+ if (!table || table.type.name !== "table") return false;
+
+ // 2) Clamp row/col to the table’s current grid
+ const map = TableMap.get(table);
+ const row = Math.max(0, Math.min(a.row, map.height - 1));
+ const col = Math.max(0, Math.min(a.col, map.width - 1));
+
+ // 3) Compute the absolute position of the target cell (pos BEFORE the cell)
+ const cellIndex = row * map.width + col;
+ const relCellPos = map.map[cellIndex]; // relative to (tablePos + 1)
+ if (relCellPos == null) return false;
+ const cellPos = tablePos + 1 + relCellPos;
+
+ // 4) Place the caret inside the cell, clamping the text offset
+ const textPos = cellPos + 1;
+ const textNode = tr.doc.nodeAt(textPos);
+ const textStart = textPos + 1;
+ const max = textNode ? textNode.content.size : 0;
+ const head = textStart + Math.max(0, Math.min(a.offset, max));
+
+ tr.setSelection(TextSelection.create(tr.doc, head));
+ return true;
+}
diff --git a/packages/xl-ai/src/prosemirror/changeset.ts b/packages/xl-ai/src/prosemirror/changeset.ts
index 26e3099a89..2bcdbee3fc 100644
--- a/packages/xl-ai/src/prosemirror/changeset.ts
+++ b/packages/xl-ai/src/prosemirror/changeset.ts
@@ -6,6 +6,7 @@ import {
TokenEncoder,
} from "prosemirror-changeset";
import { Node } from "prosemirror-model";
+import { Transaction } from "prosemirror-state";
import { ReplaceStep, Transform } from "prosemirror-transform";
type CustomChange = Change & {
@@ -198,7 +199,7 @@ export function updateToReplaceSteps(
updateToPos?: number,
) {
const blockPos = getNodeById(op.id, doc)!;
- const updatedTr = new Transform(doc);
+ const updatedTr = new Transaction(doc);
updateBlockTr(
updatedTr,
blockPos.posBeforeNode,
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 0a0122c56f..4964fbb6a6 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -750,6 +750,28 @@
"slug": "ui-components"
},
"readme": "In this example, we add a button to the Link Toolbar which opens a browser alert.\n\n**Try it out:** Hover the link open the Link Toolbar, and click the new \"Open Alert\" button!\n\n**Relevant Docs:**\n\n- [Changing the Link Toolbar](/docs/react/components/link-toolbar)\n- [Editor Setup](/docs/getting-started/editor-setup)"
+ },
+ {
+ "projectSlug": "advanced-tables-2",
+ "fullSlug": "ui-components/advanced-tables-2",
+ "pathFromRoot": "examples/03-ui-components/17-advanced-tables-2",
+ "config": {
+ "playground": true,
+ "docs": true,
+ "author": "must",
+ "tags": [
+ "Intermediate",
+ "UI Components",
+ "Tables",
+ "Appearance & Styling"
+ ]
+ },
+ "title": "Advanced Tables with Calculated Columns",
+ "group": {
+ "pathFromRoot": "examples/03-ui-components",
+ "slug": "ui-components"
+ },
+ "readme": "This example demonstrates advanced table features including automatic calculations. It shows how to create a table with calculated columns that automatically update when values change.\n\n## Features\n\n- **Automatic Calculations**: Quantity × Price = Total for each row\n- **Grand Total**: Automatically calculated sum of all totals\n- **Real-time Updates**: Calculations update immediately when you change quantity or price values\n- **Split cells**: Merge and split table cells\n- **Cell background color**: Color individual cells\n- **Cell text color**: Change text color in cells\n- **Table row and column headers**: Use headers for better organization\n\n## How It Works\n\nThe example uses the `onChange` event listener to detect when table content changes. When a table is updated, it automatically:\n\n1. Extracts quantity and price values from each data row\n2. Calculates the total (quantity × price) for each row\n3. Updates the total column with the calculated values\n4. Calculates and updates the grand total\n\n## Code Highlights\n\n```tsx\n {\n const changes = getChanges();\n\n changes.forEach((change) => {\n if (change.type === \"update\" && change.block.type === \"table\") {\n const updatedRows = calculateTableTotals(change.block);\n if (updatedRows) {\n editor.updateBlock(change.block, {\n type: \"table\",\n content: {\n ...change.block.content,\n rows: updatedRows as any,\n } as any,\n });\n }\n }\n });\n }}\n>\n```\n\n**Relevant Docs:**\n\n- [Tables](/docs/features/blocks/tables)\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Events](/docs/reference/editor/events)"
}
]
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bec8df27c4..9e2394ecdd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1721,6 +1721,43 @@ importers:
specifier: ^5.3.4
version: 5.4.15(@types/node@22.15.2)(lightningcss@1.30.1)(terser@5.43.1)
+ examples/03-ui-components/17-advanced-tables-2:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ react:
+ specifier: ^19.1.0
+ version: 19.1.0
+ react-dom:
+ specifier: ^19.1.0
+ version: 19.1.0(react@19.1.0)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.1.0
+ version: 19.1.8
+ '@types/react-dom':
+ specifier: ^19.1.0
+ version: 19.1.6(@types/react@19.1.8)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.1
+ version: 4.4.1(vite@5.4.15(@types/node@22.15.2)(lightningcss@1.30.1)(terser@5.43.1))
+ vite:
+ specifier: ^5.3.4
+ version: 5.4.15(@types/node@22.15.2)(lightningcss@1.30.1)(terser@5.43.1)
+
examples/04-theming/01-theming-dom-attributes:
dependencies:
'@blocknote/ariakit':