Skip to content
Open
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
13 changes: 7 additions & 6 deletions packages/core/src/api/clipboard/toClipboard/copyExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,17 @@ function fragmentToExternalHTML<
);

if (isWithinTable) {
if (selectedFragment.firstChild?.type.name === "table") {
// contentNodeToTableContent expects the fragment of the content of a table, not the table node itself
// but cellselection.content() returns the table node itself if all cells and columns are selected
selectedFragment = selectedFragment.firstChild.content;
}
// A full-table selection already includes the table node; partial cell
// selections contain rows that need a table wrapper for TableMap.
const table =
selectedFragment.firstChild?.type.name === "table"
? selectedFragment.firstChild
: view.state.schema.nodes.table.create(null, selectedFragment);

// first convert selection to blocknote-style table content, and then
// pass this to the exporter
const ic = contentNodeToTableContent(
selectedFragment as any,
table,
editor.schema.inlineContentSchema,
editor.schema.styleSchema,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { UniqueID } from "../../../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import { DOMSerializer, Fragment, Node } from "prosemirror-model";

import { createTableSerializer } from "../../../../blocks/Table/TableAccessibility/createTableSerializer.js";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
Expand Down Expand Up @@ -57,7 +59,7 @@ export function serializeInlineContentExternalHTML<
editor: BlockNoteEditor<any, I, S>,
blockContent: PartialBlock<BSchema, I, S>["content"],
serializer: DOMSerializer,
options?: { document?: Document; blockType?: string },
options?: { document?: Document; blockType?: string; blockId?: string },
) {
let nodes: Node[];

Expand All @@ -78,6 +80,12 @@ export function serializeInlineContentExternalHTML<
);
} else if (blockContent.type === "tableContent") {
nodes = tableContentToNodes(blockContent, editor.pmSchema);
serializer = createTableSerializer(
serializer,
editor.pmSchema.nodes.table.create(null, nodes),
options?.blockId ?? UniqueID.options.generateID(),
options?.document ?? document,
);
} else {
throw new UnreachableCaseError(blockContent.type);
}
Expand Down Expand Up @@ -351,7 +359,11 @@ function serializeBlock<
editor,
block.content as any, // TODO
serializer,
{ ...options, blockType: block.type },
{
...options,
blockType: block.type,
blockId: block.id,
},
);

ret.contentDOM.appendChild(ic);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { UniqueID } from "../../../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import { DOMSerializer, Fragment, Node } from "prosemirror-model";

import { createTableSerializer } from "../../../../blocks/Table/TableAccessibility/createTableSerializer.js";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
Expand Down Expand Up @@ -29,7 +31,7 @@ export function serializeInlineContentInternalHTML<
blockContent: PartialBlock<BSchema, I, S>["content"],
serializer: DOMSerializer,
blockType?: string,
options?: { document?: Document },
options?: { document?: Document; blockId?: string },
) {
let nodes: Node[];

Expand All @@ -42,6 +44,12 @@ export function serializeInlineContentInternalHTML<
nodes = inlineContentToNodes(blockContent, editor.pmSchema, blockType);
} else if (blockContent.type === "tableContent") {
nodes = tableContentToNodes(blockContent, editor.pmSchema);
serializer = createTableSerializer(
serializer,
editor.pmSchema.nodes.table.create(null, nodes),
options?.blockId ?? UniqueID.options.generateID(),
options?.document ?? document,
);
} else {
throw new UnreachableCaseError(blockContent.type);
}
Expand Down Expand Up @@ -196,7 +204,10 @@ function serializeBlock<
block.content as any, // TODO
serializer,
block.type,
options,
{
...options,
blockId: block.id,
},
);
ret.contentDOM.appendChild(ic);
}
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/api/nodeConversions/blockToNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,17 +215,17 @@ export function tableContentToNodes<
// each cell's relative index to its absolute column index. The occupancy
// grid is built and scanned once per table, since doing so for every cell
// makes the conversion quadratic in the number of cells.
const getAbsolute = getRelativeToAbsoluteTableCellMapping(
{ type: "table", content: tableContent } as any,
);
const getAbsolute = getRelativeToAbsoluteTableCellMapping({
type: "table",
content: tableContent,
} as any);

for (let rowIndex = 0; rowIndex < tableContent.rows.length; rowIndex++) {
const row = tableContent.rows[rowIndex];
const columnNodes: Node[] = [];
const isHeaderRow = headerRows[rowIndex];
for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex++) {
const cell = row.cells[cellIndex];
const isHeaderCol = headerCols[cellIndex];
/**
* The attributes of the cell to apply to the node
*/
Expand All @@ -240,6 +240,8 @@ export function tableContentToNodes<
col: cellIndex,
});

const isHeaderCol = headerCols[absoluteColumnIndex];

// Assume the column width is the width of the cell at the absolute cell index
let colwidth: (number | undefined)[] | null = columnWidths[
absoluteColumnIndex
Expand Down
23 changes: 12 additions & 11 deletions packages/core/src/api/nodeConversions/nodeToBlock.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TableMap } from "prosemirror-tables";
import { Mark, Node, Slice } from "@tiptap/pm/model";
import type { Block } from "../../blocks/defaultBlocks.js";
import { isLayoutNode } from "../../schema/blocks/children.js";
Expand Down Expand Up @@ -72,11 +73,16 @@ export function contentNodeToTableContent<
rows: [],
};

/**
* A matrix of boolean values indicating whether a cell is a header.
* The first index is the row index, the second index is the cell index.
*/
const headerMatrix: boolean[][] = [];
// Include slots covered by row/column spans when identifying header bands.
const map = TableMap.get(contentNode);
const headerMatrix = Array.from({ length: map.height }, (_, row) =>
Array.from(
{ length: map.width },
(_, col) =>
contentNode.nodeAt(map.map[row * map.width + col])?.type.spec
.tableRole === "header_cell",
),
);

contentNode.content.forEach((rowNode, _offset, rowIndex) => {
const row: TableContent<I, S>["rows"][0] = {
Expand All @@ -91,12 +97,7 @@ export function contentNodeToTableContent<
});
}

row.cells = rowNode.content.content.map((cellNode, cellIndex) => {
if (!headerMatrix[rowIndex]) {
headerMatrix[rowIndex] = [];
}
// Mark the cell as a header if it is a tableHeader node.
headerMatrix[rowIndex][cellIndex] = cellNode.type.name === "tableHeader";
row.cells = rowNode.content.content.map((cellNode) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused why this changed

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the above change you commented on fix a bug with converting to/from TipTap nodes. Check commit c141d42 which adds a test case for it. When either of the changes is reverted, the test fails on round trip.

// Convert cell content to inline content and merge adjacent styled text nodes
const content = cellNode.content.content
.map((child) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Node as PMNode } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import {
applyCellHeaders,
applyHeaderId,
applyHeaderScope,
} from "./cellAttributes.js";

export function TableAccessibilityPlugin() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused on why we are doing this via decorations and serializers, why are we not just adding the attributes to the table cells/headers

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To get the value for headers we need the position of the cell, so we need to use a node view. I tried doing that instead of using decorations but settled on decorations because it was more readable basically.

const accessibilityKey = new PluginKey<DecorationSet>("tableAccessibility");

function createAccessibilityDecorations(doc: PMNode) {
const decorations: Decoration[] = [];
doc.descendants((table, tablePosition, parent) => {
if (table.type.spec.tableRole !== "table") {
return;
}
const tableId = parent!.attrs.id;
table.forEach((row, rowOffset) => {
row.forEach((cell, cellOffset) => {
// TableMap positions start at table content; decorations use
// absolute document positions, including the table's opening token.
const position = rowOffset + cellOffset + 1;
const attributes: Record<string, string> = {};
if (cell.type.spec.tableRole === "header_cell") {
applyHeaderScope(attributes, table, position);
applyHeaderId(attributes, table, tableId, position);
} else {
applyCellHeaders(attributes, table, tableId, position);
}
const from = tablePosition + 1 + position;
decorations.push(
Decoration.node(from, from + cell.nodeSize, attributes),
);
});
});
return false;
});
return DecorationSet.create(doc, decorations);
}

return new Plugin<DecorationSet>({
key: accessibilityKey,
state: {
init: (_, state) => createAccessibilityDecorations(state.doc),
// Recompute relationships after edits; selection changes reuse them.
apply: (transaction, decorations) =>
transaction.docChanged
? createAccessibilityDecorations(transaction.doc)
: decorations,
},
props: {
decorations: (state) => accessibilityKey.getState(state),
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { TableMap } from "prosemirror-tables";
import { Node } from "prosemirror-model";

function getHeaderScope(table: Node, position: number) {
const map = TableMap.get(table);
const { top, left } = map.findCell(position);
const isHeader = (position: number) =>
table.nodeAt(position)?.type.spec.tableRole === "header_cell";

// A full header row identifies column headers, including intersections with
// header columns. TableMap includes slots covered by merged cells.
if (map.map.slice(top * map.width, (top + 1) * map.width).every(isHeader)) {
return "col";
}
for (let row = 0; row < map.height; row++) {
if (!isHeader(map.map[row * map.width + left])) {
return undefined;
}
}
return "row";
}

function getHeaderId(
tableId: string,
scope: "row" | "col",
row: number,
col: number,
) {
// Both coordinates distinguish headers in multiple header rows or columns.
return `bn-table-${tableId}-${scope}-header-${row}-${col}`;
}

export function applyCellHeaders(
attributes: Record<string, string>,
table: Node,
tableId: string,
cellPosition: number,
) {
const ids = new Set<string>();
const map = TableMap.get(table);
const rect = map.findCell(cellPosition);

function addHeader(position: number, scope: "row" | "col") {
if (
table.nodeAt(position)?.type.spec.tableRole !== "header_cell" ||
getHeaderScope(table, position) !== scope
) {
return;
}
const { top, left } = map.findCell(position);
ids.add(getHeaderId(tableId, scope, top, left));
}

// Search every header row/column along the cell's covered grid indices.
// A merged header may occupy several slots; the Set keeps one reference.
for (let row = 0; row < map.height; row++) {
for (let col = rect.left; col < rect.right; col++) {
addHeader(map.map[row * map.width + col], "col");
}
}
for (let row = rect.top; row < rect.bottom; row++) {
for (let col = 0; col < map.width; col++) {
addHeader(map.map[row * map.width + col], "row");
}
}
if (ids.size) {
attributes.headers = [...ids].join(" ");
} else {
delete attributes.headers;
}
}

export function applyHeaderScope(
attributes: Record<string, string>,
table: Node,
position: number,
) {
const scope = getHeaderScope(table, position);
if (scope) {
attributes.scope = scope;
} else {
delete attributes.scope;
}
}

export function applyHeaderId(
attributes: Record<string, string>,
table: Node,
tableId: string,
position: number,
) {
const scope = getHeaderScope(table, position);
if (scope) {
const { top, left } = TableMap.get(table).findCell(position);
attributes.id = getHeaderId(tableId, scope, top, left);
} else {
delete attributes.id;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { DOMSerializer, Node } from "prosemirror-model";
import {
applyCellHeaders,
applyHeaderId,
applyHeaderScope,
} from "./cellAttributes.js";

/** Give cell serializers the table position that ProseMirror's toDOM omits. */
export function createTableSerializer(
serializer: DOMSerializer,
table: Node,
tableId: string,
document: Document,
) {
const positions = new Map<Node, number>();
table.forEach((row, rowOffset) => {
row.forEach((cell, cellOffset) => {
positions.set(cell, rowOffset + cellOffset + 1);
});
});

function renderCell(cell: Node) {
const position = positions.get(cell);
if (position === undefined) {
throw new Error("Cannot serialize a cell outside its table");
}
const attributes: Record<string, string> = {};
if (cell.type.spec.tableRole === "header_cell") {
applyHeaderScope(attributes, table, position);
applyHeaderId(attributes, table, tableId, position);
} else {
applyCellHeaders(attributes, table, tableId, position);
}

// Preserve the existing renderer's spans, styles, and content hole. The
// serializer will fill that hole with the cell's content after this returns.
const output = DOMSerializer.renderSpec(
document,
serializer.nodes[cell.type.name](cell),
);
if (!(output.dom instanceof HTMLElement)) {
throw new Error("Table cells must render an HTML element");
}
for (const [name, value] of Object.entries(attributes)) {
output.dom.setAttribute(name, value);
}
return output;
}

return new DOMSerializer(
{ ...serializer.nodes, tableHeader: renderCell, tableCell: renderCell },
serializer.marks,
);
}
Loading
Loading