diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index 0a267d1b43..6d16a83870 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -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 { @@ -57,7 +59,7 @@ export function serializeInlineContentExternalHTML< editor: BlockNoteEditor, blockContent: PartialBlock["content"], serializer: DOMSerializer, - options?: { document?: Document; blockType?: string }, + options?: { document?: Document; blockType?: string; blockId?: string }, ) { let nodes: Node[]; @@ -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); } @@ -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); diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 3a5ed1e357..725f7f1b86 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -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 { @@ -29,7 +31,7 @@ export function serializeInlineContentInternalHTML< blockContent: PartialBlock["content"], serializer: DOMSerializer, blockType?: string, - options?: { document?: Document }, + options?: { document?: Document; blockId?: string }, ) { let nodes: Node[]; @@ -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); } @@ -196,7 +204,10 @@ function serializeBlock< block.content as any, // TODO serializer, block.type, - options, + { + ...options, + blockId: block.id, + }, ); ret.contentDOM.appendChild(ic); } diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 7728ecf163..ca3de0f5f6 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -217,7 +217,6 @@ export function tableContentToNodes< 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 */ @@ -236,6 +235,8 @@ export function tableContentToNodes< { type: "table", content: tableContent } as any, ); + const isHeaderCol = headerCols[absoluteCellIndex.col]; + // Assume the column width is the width of the cell at the absolute cell index let colwidth: (number | undefined)[] | null = columnWidths[ absoluteCellIndex.col diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 7f378075a4..f11c69036f 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -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"; @@ -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["rows"][0] = { @@ -93,12 +99,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) => { // Convert cell content to inline content and merge adjacent styled text nodes const content = cellNode.content.content .map((child) => diff --git a/packages/core/src/blocks/Table/TableAccessibility/TableAccessibilityPlugin.ts b/packages/core/src/blocks/Table/TableAccessibility/TableAccessibilityPlugin.ts new file mode 100644 index 0000000000..ba995ed18c --- /dev/null +++ b/packages/core/src/blocks/Table/TableAccessibility/TableAccessibilityPlugin.ts @@ -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() { + const accessibilityKey = new PluginKey("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 = {}; + 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({ + 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), + }, + }); +} diff --git a/packages/core/src/blocks/Table/TableAccessibility/cellAttributes.ts b/packages/core/src/blocks/Table/TableAccessibility/cellAttributes.ts new file mode 100644 index 0000000000..ab5e5faf37 --- /dev/null +++ b/packages/core/src/blocks/Table/TableAccessibility/cellAttributes.ts @@ -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, + table: Node, + tableId: string, + cellPosition: number, +) { + const ids = new Set(); + 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, + table: Node, + position: number, +) { + const scope = getHeaderScope(table, position); + if (scope) { + attributes.scope = scope; + } else { + delete attributes.scope; + } +} + +export function applyHeaderId( + attributes: Record, + 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; + } +} diff --git a/packages/core/src/blocks/Table/TableAccessibility/createTableSerializer.ts b/packages/core/src/blocks/Table/TableAccessibility/createTableSerializer.ts new file mode 100644 index 0000000000..b92f2a0e88 --- /dev/null +++ b/packages/core/src/blocks/Table/TableAccessibility/createTableSerializer.ts @@ -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(); + 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 = {}; + 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, + ); +} diff --git a/packages/core/src/blocks/Table/TableExtension.ts b/packages/core/src/blocks/Table/TableExtension.ts index 70cea2ee9f..642816ba0b 100644 --- a/packages/core/src/blocks/Table/TableExtension.ts +++ b/packages/core/src/blocks/Table/TableExtension.ts @@ -10,6 +10,8 @@ import { tableEditing, } from "prosemirror-tables"; +import { TableAccessibilityPlugin } from "./TableAccessibility/TableAccessibilityPlugin.js"; + export const RESIZE_MIN_WIDTH = 35; export const EMPTY_CELL_WIDTH = 120; export const EMPTY_CELL_HEIGHT = 31; @@ -17,8 +19,9 @@ export const EMPTY_CELL_HEIGHT = 31; export const TableExtension = Extension.create({ name: "BlockNoteTableExtension", - addProseMirrorPlugins: () => { + addProseMirrorPlugins() { return [ + TableAccessibilityPlugin(), columnResizing({ cellMinWidth: RESIZE_MIN_WIDTH, defaultCellMinWidth: EMPTY_CELL_WIDTH, diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/advancedExample.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/advancedExample.html index 1327b27445..3d77a0b2d3 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/advancedExample.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/advancedExample.html @@ -15,7 +15,14 @@ - +

This row has headers

This is @@ -36,19 +45,33 @@ colspan="1" rowspan="1" colwidth="201" + scope="col" + id="bn-table-1-col-header-0-2" >

Text is Blue

- +

This spans 2 columns
and 2 rows

- +

Sooo many features

@@ -59,18 +82,31 @@ colspan="1" rowspan="1" colwidth="201" + headers="bn-table-1-col-header-0-2" >

- +

A cell

- +

Another Cell

- +

Aligned center

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerCols.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerCols.html index 7a80c38aec..098e4edd1a 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerCols.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerCols.html @@ -15,35 +15,35 @@ - +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerRows.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerRows.html index f8c94c7f66..ad8f005c9e 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerRows.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerRows.html @@ -15,35 +15,35 @@ - +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerRowsAndCols.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerRowsAndCols.html index 945741a785..6f82848da5 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerRowsAndCols.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/table/headerRowsAndCols.html @@ -15,24 +15,32 @@ - +

Corner

- +

Column Header 1

- +

Column Header 2

- +

Row Header 1

- +

Data 1

- +

Data 2

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/advancedExample.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/advancedExample.html index a4f5dfcb31..009a68e460 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/advancedExample.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/advancedExample.html @@ -5,7 +5,14 @@ - +

This row has headers

This is @@ -26,19 +35,33 @@ colspan="1" rowspan="1" colwidth="201" + scope="col" + id="bn-table-1-col-header-0-2" >

Text is Blue

- +

This spans 2 columns
and 2 rows

- +

Sooo many features

@@ -49,18 +72,31 @@ colspan="1" rowspan="1" colwidth="201" + headers="bn-table-1-col-header-0-2" >

- +

A cell

- +

Another Cell

- +

Aligned center

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerCols.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerCols.html index ca53a4c2b5..206cea4de1 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerCols.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerCols.html @@ -5,35 +5,35 @@ - +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerRows.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerRows.html index e6314b2629..fcc84be2a8 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerRows.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerRows.html @@ -5,35 +5,35 @@ - +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

- +

Table Cell

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerRowsAndCols.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerRowsAndCols.html index 64ee4183aa..68dfa6bd43 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerRowsAndCols.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/table/headerRowsAndCols.html @@ -5,24 +5,32 @@ - +

Corner

- +

Column Header 1

- +

Column Header 2

- +

Row Header 1

- +

Data 1

- +

Data 2