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
5 changes: 5 additions & 0 deletions .changeset/table-cell-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-table': minor
---

Added Markdown table alignment support. The `TableCell` and `TableHeader` nodes now have an `align` attribute (`left`, `center`, `right`) that is parsed from Markdown column alignment markers (`:---`, `---:`, `:---:`) and serialized back when rendering to Markdown. Alignment is also parsed from and rendered to HTML via `style="text-align: ..."`.
12 changes: 6 additions & 6 deletions demos/src/Markdown/Parse/React/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { TableKit } from '@tiptap/extension-table'
import { Markdown } from '@tiptap/markdown'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'

const markdown = `# Markdown parsing demo

Expand Down Expand Up @@ -38,10 +37,11 @@ ${'```'}

## Table

| Name | Feature |
| ---- | ------- |
| A | Headings |
| B | Lists |
| Name | Feature | Left Column | Center Column | Right Column |
| ---- | ------- | :---------- | :-----------: | -----------: |
| A | Headings |left|centered|$1,600|
| B | Lists |left|centered|$12|
| C | Markdown |left|centered|$1|

## Image (example)

Expand All @@ -58,7 +58,7 @@ Feel free to click "Parse Markdown" or upload a \`.md\` file to test parsing wit

export default () => {
const editor = useEditor({
extensions: [Markdown, StarterKit, Image, TableKit],
extensions: [Markdown, StarterKit, Image, TableKit.configure({ table: { cellMinWidth: 150 } })],
content: `
<p>In this demo you can parse Markdown content into Tiptap on the client-side via <code>@tiptap/markdown</code>.</p>
<p>Click the button above or use your own markdown file to test it out.</p>
Expand Down
34 changes: 34 additions & 0 deletions packages/extension-table/__tests__/tableMarkdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vitest'

describe('table markdown alignment', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, TableKit],
})

it('should parse and serialize left/right/center table alignment', () => {
const markdown = `| left | right | center |
| :---- | ----: | :-----: |
| a | b | c |`

const parsed = markdownManager.parse(markdown)
const table = parsed.content?.[0]

expect(table?.type).toBe('table')

const headerCells = table?.content?.[0]?.content || []

expect(headerCells[0]?.attrs?.align).toBe('left')
expect(headerCells[1]?.attrs?.align).toBe('right')
expect(headerCells[2]?.attrs?.align).toBe('center')

const serialized = markdownManager.serialize(parsed)

expect(serialized).toContain('| left | right | center |')
expect(serialized).toMatch(/\|\s*:[-]+\s*\|\s*[-]+:\s*\|\s*:[-]+:\s*\|/)
})
})
3 changes: 3 additions & 0 deletions packages/extension-table/src/cell/table-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import '../types.js'

import { mergeAttributes, Node } from '@tiptap/core'

import { createAlignAttribute } from '../utilities/parseAlign.js'

export interface TableCellOptions {
/**
* The HTML attributes for a table cell node.
Expand Down Expand Up @@ -54,6 +56,7 @@ export const TableCell = Node.create<TableCellOptions>({
return value
},
},
align: createAlignAttribute(),
}
},

Expand Down
3 changes: 3 additions & 0 deletions packages/extension-table/src/header/table-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import '../types.js'

import { mergeAttributes, Node } from '@tiptap/core'

import { createAlignAttribute } from '../utilities/parseAlign.js'

export interface TableHeaderOptions {
/**
* The HTML attributes for a table header node.
Expand Down Expand Up @@ -43,6 +45,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
return value
},
},
align: createAlignAttribute(),
}
},

Expand Down
23 changes: 17 additions & 6 deletions packages/extension-table/src/table/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ import {
} from '@tiptap/pm/tables'
import type { EditorView, NodeView } from '@tiptap/pm/view'

import { type TableCellAlign, normalizeTableCellAlign } from '../utilities/parseAlign.js'
import { TableView } from './TableView.js'
import { createColGroup } from './utilities/createColGroup.js'
import { createTable } from './utilities/createTable.js'
import { deleteTableWhenAllCellsSelected } from './utilities/deleteTableWhenAllCellsSelected.js'
import renderTableToMarkdown from './utilities/markdown.js'

type MarkdownTableToken = {
header?: { tokens: MarkdownToken[] }[]
rows?: { tokens: MarkdownToken[] }[][]
align?: Array<TableCellAlign | null>
header?: { tokens: MarkdownToken[]; align?: TableCellAlign | null }[]
rows?: { tokens: MarkdownToken[]; align?: TableCellAlign | null }[][]
} & MarkdownToken

export interface TableOptions {
Expand Down Expand Up @@ -304,12 +306,18 @@ export const Table = Node.create<TableOptions>({

parseMarkdown: (token: MarkdownTableToken, h) => {
const rows = []
const alignments = Array.isArray(token.align) ? token.align : []

if (token.header) {
const headerCells: JSONContent[] = []

token.header.forEach(cell => {
headerCells.push(h.createNode('tableHeader', {}, [{ type: 'paragraph', content: h.parseInline(cell.tokens) }]))
token.header.forEach((cell, index) => {
const align = normalizeTableCellAlign(alignments[index] ?? cell.align)
const attrs = align ? { align } : {}

headerCells.push(
h.createNode('tableHeader', attrs, [{ type: 'paragraph', content: h.parseInline(cell.tokens) }]),
)
})

rows.push(h.createNode('tableRow', {}, headerCells))
Expand All @@ -318,8 +326,11 @@ export const Table = Node.create<TableOptions>({
if (token.rows) {
token.rows.forEach(row => {
const bodyCells: JSONContent[] = []
row.forEach(cell => {
bodyCells.push(h.createNode('tableCell', {}, [{ type: 'paragraph', content: h.parseInline(cell.tokens) }]))
row.forEach((cell, index) => {
const align = normalizeTableCellAlign(alignments[index] ?? cell.align)
const attrs = align ? { align } : {}

bodyCells.push(h.createNode('tableCell', attrs, [{ type: 'paragraph', content: h.parseInline(cell.tokens) }]))
})
rows.push(h.createNode('tableRow', {}, bodyCells))
})
Expand Down
47 changes: 41 additions & 6 deletions packages/extension-table/src/table/utilities/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/core'

import {
type TableCellAlign as TableCellAlignType,
normalizeTableCellAlignFromAttributes,
TableCellAlign,
} from '../../utilities/parseAlign.js'

export const DEFAULT_CELL_LINE_SEPARATOR = '\u001F'

function collapseWhitespace(s: string) {
Expand All @@ -17,11 +23,11 @@ export function renderTableToMarkdown(
return ''
}

// Build rows: each cell is { text, isHeader }
const rows: { text: string; isHeader: boolean }[][] = []
// Build rows: each cell is { text, isHeader, align }
const rows: { text: string; isHeader: boolean; align: TableCellAlignType | null }[][] = []

node.content.forEach(rowNode => {
const cells: { text: string; isHeader: boolean }[] = []
const cells: { text: string; isHeader: boolean; align: TableCellAlignType | null }[] = []

if (rowNode.content) {
rowNode.content.forEach(cellNode => {
Expand All @@ -37,8 +43,9 @@ export function renderTableToMarkdown(

const text = collapseWhitespace(raw)
const isHeader = cellNode.type === 'tableHeader'
const align = normalizeTableCellAlignFromAttributes(cellNode.attrs)

cells.push({ text, isHeader })
cells.push({ text, isHeader, align })
})
}

Expand Down Expand Up @@ -72,6 +79,15 @@ export function renderTableToMarkdown(

const headerRow = rows[0]
const hasHeader = headerRow.some(c => c.isHeader)
const colAlignments: Array<TableCellAlignType | null> = new Array(columnCount).fill(null)

rows.forEach(r => {
for (let i = 0; i < columnCount; i += 1) {
if (!colAlignments[i] && r[i]?.align) {
colAlignments[i] = r[i].align
}
}
})

let out = '\n'

Expand All @@ -84,8 +100,27 @@ export function renderTableToMarkdown(

out += `| ${headerTexts.map((t, i) => pad(t, colWidths[i])).join(' | ')} |\n`

// Separator (use at least 3 dashes per column)
out += `| ${colWidths.map(w => '-'.repeat(Math.max(3, w))).join(' | ')} |\n`
// Separator (use at least 3 dashes per column and include alignment markers)
out += `| ${colWidths
.map((w, index) => {
const dashCount = Math.max(3, w)
const alignment = colAlignments[index]

if (alignment === TableCellAlign.Left) {
return `:${'-'.repeat(dashCount)}`
}

if (alignment === TableCellAlign.Right) {
return `${'-'.repeat(dashCount)}:`
}

if (alignment === TableCellAlign.Center) {
return `:${'-'.repeat(dashCount)}:`
}

return '-'.repeat(dashCount)
})
.join(' | ')} |\n`

// Body rows: if we had a header, skip the first row; otherwise render all rows
const body = hasHeader ? rows.slice(1) : rows
Expand Down
74 changes: 74 additions & 0 deletions packages/extension-table/src/utilities/parseAlign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Attribute } from '@tiptap/core'

/**
* Supported table cell alignment values
*/
export enum TableCellAlign {
Left = 'left',
Right = 'right',
Center = 'center',
}

/**
* Normalize unknown input into a supported table alignment
*
* @param value - A potential alignment value
* @returns A valid TableCellAlign value or null
*/
export function normalizeTableCellAlign(value: unknown): TableCellAlign | null {
if (value === TableCellAlign.Left || value === TableCellAlign.Right || value === TableCellAlign.Center) {
return value
}

return null
}

/**
* Parse table cell alignment from an HTML element
*
* Prefers inline style (${"`"}text-align${"`"}) and falls back to the legacy
* ${"`"}align${"`"} attribute.
*
* @param element - The table cell/header DOM element
* @returns A valid TableCellAlign value or null
*/
export function parseAlign(element: HTMLElement): TableCellAlign | null {
const styleAlign = (element.style.textAlign || '').trim().toLowerCase()
const attrAlign = (element.getAttribute('align') || '').trim().toLowerCase()
const align = styleAlign || attrAlign

return normalizeTableCellAlign(align)
}

/**
* Normalize alignment from a generic attrs object that may include an align field
*
* @param attributes - A node attrs-like object with an optional align field
* @returns A valid TableCellAlign value or null.
*/
export function normalizeTableCellAlignFromAttributes(
attributes: { align?: TableCellAlign } | null | undefined,
): TableCellAlign | null {
return normalizeTableCellAlign(attributes?.align)
}

/**
* Create a reusable Tiptap attribute config for table alignment
*
* @returns A Tiptap Attribute definition that parses and renders table alignment
*/
export function createAlignAttribute(): Attribute {
return {
default: null,
parseHTML: (element: HTMLElement) => parseAlign(element),
renderHTML: (attributes: { align?: TableCellAlign | null }) => {
if (!attributes.align) {
return {}
}

return {
style: `text-align: ${attributes.align}`,
}
},
}
}
Loading