snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
2231
packages/extension-table/CHANGELOG.md
Normal file
2231
packages/extension-table/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-table/README.md
Normal file
18
packages/extension-table/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-table
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-table)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-table)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation can be found on the [Tiptap website](https://tiptap.dev).
|
||||
|
||||
## License
|
||||
|
||||
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
|
||||
92
packages/extension-table/package.json
Normal file
92
packages/extension-table/package.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "@tiptap/extension-table",
|
||||
"version": "3.30.3",
|
||||
"description": "table extension for tiptap",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap extension"
|
||||
],
|
||||
"homepage": "https://tiptap.dev",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ueberdosis/tiptap/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/extension-table"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./table": {
|
||||
"types": {
|
||||
"import": "./dist/table/index.d.ts",
|
||||
"require": "./dist/table/index.d.cts"
|
||||
},
|
||||
"import": "./dist/table/index.js",
|
||||
"require": "./dist/table/index.cjs"
|
||||
},
|
||||
"./cell": {
|
||||
"types": {
|
||||
"import": "./dist/cell/index.d.ts",
|
||||
"require": "./dist/cell/index.d.cts"
|
||||
},
|
||||
"import": "./dist/cell/index.js",
|
||||
"require": "./dist/cell/index.cjs"
|
||||
},
|
||||
"./header": {
|
||||
"types": {
|
||||
"import": "./dist/header/index.d.ts",
|
||||
"require": "./dist/header/index.d.cts"
|
||||
},
|
||||
"import": "./dist/header/index.js",
|
||||
"require": "./dist/header/index.cjs"
|
||||
},
|
||||
"./kit": {
|
||||
"types": {
|
||||
"import": "./dist/kit/index.d.ts",
|
||||
"require": "./dist/kit/index.d.cts"
|
||||
},
|
||||
"import": "./dist/kit/index.js",
|
||||
"require": "./dist/kit/index.cjs"
|
||||
},
|
||||
"./row": {
|
||||
"types": {
|
||||
"import": "./dist/row/index.d.ts",
|
||||
"require": "./dist/row/index.d.cts"
|
||||
},
|
||||
"import": "./dist/row/index.js",
|
||||
"require": "./dist/row/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "workspace:^",
|
||||
"@tiptap/pm": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "workspace:*",
|
||||
"@tiptap/pm": "workspace:*"
|
||||
}
|
||||
}
|
||||
1
packages/extension-table/src/cell/index.ts
Normal file
1
packages/extension-table/src/cell/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './table-cell.js'
|
||||
160
packages/extension-table/src/cell/table-cell.test.ts
Normal file
160
packages/extension-table/src/cell/table-cell.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { TableHeader } from '../header/table-header.js'
|
||||
import { TableRow } from '../row/table-row.js'
|
||||
import { Table } from '../table/table.js'
|
||||
import { TableCell } from './table-cell.js'
|
||||
|
||||
describe('extension table cell', () => {
|
||||
const editorElClass = 'tiptap'
|
||||
let editor: Editor | null = null
|
||||
|
||||
const createEditorEl = () => {
|
||||
const editorEl = document.createElement('div')
|
||||
|
||||
editorEl.classList.add(editorElClass)
|
||||
document.body.appendChild(editorEl)
|
||||
return editorEl
|
||||
}
|
||||
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
|
||||
|
||||
it('should start with a Table', () => {
|
||||
const content =
|
||||
'<table style="width:100%"><tr><td>Firstname</td><td>Lastname</td><td>Age</td></tr><tr><td>Jill</td><td>Smith</td><td>50</td></tr><tr><td>Eve</td><td>Jackson</td><td>94</td></tr><tr><td>John</td><td>Doe</td><td>80</td></tr></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).toContain('Jackson')
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse a single colWidth', () => {
|
||||
const content =
|
||||
'<table><tbody><tr><td colwidth="200">Name</td><td>Description</td></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
// @ts-expect-error content is not guaranteed to be this shape
|
||||
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).toBe(200)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse multiple colWidths', () => {
|
||||
const content =
|
||||
'<table><tbody><tr><td colwidth="200">Name</td><td colspan="3" colwidth="150,100">Description</td></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
// @ts-expect-error content is not guaranteed to be this shape
|
||||
expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).toEqual([150, 100])
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse the colgroup col width for the first column', () => {
|
||||
const content =
|
||||
'<table><colgroup><col width="64" /><col /></colgroup><tbody><tr><td>hello</td><td>world</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
const firstRow = editor.getJSON().content?.[0].content?.[0]
|
||||
|
||||
expect(firstRow?.content?.[0].attrs?.colwidth).toEqual([64])
|
||||
expect(firstRow?.content?.[1].attrs?.colwidth).toBeNull()
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should prefer the colwidth attribute over the colgroup col width', () => {
|
||||
const content =
|
||||
'<table><colgroup><col width="64" /><col /></colgroup><tbody><tr><td colwidth="200">hello</td><td>world</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
// @ts-expect-error content is not guaranteed to be this shape
|
||||
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth).toEqual([200])
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
68
packages/extension-table/src/cell/table-cell.ts
Normal file
68
packages/extension-table/src/cell/table-cell.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import '../types.js'
|
||||
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
|
||||
import { createAlignAttribute } from '../utils/parseAlign.js'
|
||||
import { parseColwidth } from '../utils/parseColwidth.js'
|
||||
import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js'
|
||||
|
||||
export interface TableCellOptions {
|
||||
/**
|
||||
* The HTML attributes for a table cell node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to create table cells.
|
||||
* @see https://www.tiptap.dev/api/nodes/table-cell
|
||||
*/
|
||||
export const TableCell = Node.create<TableCellOptions>({
|
||||
name: 'tableCell',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
content: 'block+',
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1,
|
||||
},
|
||||
rowspan: {
|
||||
default: 1,
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: parseColwidth,
|
||||
},
|
||||
align: createAlignAttribute(),
|
||||
}
|
||||
},
|
||||
|
||||
tableRole: 'cell',
|
||||
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
// Backfill empty cells; non-empty cells fall through to the rule below.
|
||||
tag: 'td',
|
||||
getAttrs: node => (isEmptyCellElement(node) ? {} : false),
|
||||
getContent: (_node, schema) => fillEmptyCellContent(schema.nodes[this.name]),
|
||||
},
|
||||
{ tag: 'td' },
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
},
|
||||
})
|
||||
1
packages/extension-table/src/header/index.ts
Normal file
1
packages/extension-table/src/header/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './table-header.js'
|
||||
133
packages/extension-table/src/header/table-header.test.ts
Normal file
133
packages/extension-table/src/header/table-header.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { TableCell } from '../cell/table-cell.js'
|
||||
import { TableRow } from '../row/table-row.js'
|
||||
import { Table } from '../table/table.js'
|
||||
import { TableHeader } from './table-header.js'
|
||||
|
||||
describe('extension table header', () => {
|
||||
const editorElClass = 'tiptap'
|
||||
let editor: Editor | null = null
|
||||
|
||||
const createEditorEl = () => {
|
||||
const editorEl = document.createElement('div')
|
||||
|
||||
editorEl.classList.add(editorElClass)
|
||||
document.body.appendChild(editorEl)
|
||||
return editorEl
|
||||
}
|
||||
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
|
||||
|
||||
it('should start with a Table', () => {
|
||||
const content =
|
||||
'<table style="width:100%"><tr><th>Firstname</th><th>Lastname</th><th>Age</th></tr><tr><td>Jill</td><td>Smith</td><td>50</td></tr><tr><td>Eve</td><td>Jackson</td><td>94</td></tr><tr><td>John</td><td>Doe</td><td>80</td></tr></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).toContain('Jackson')
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse a single colWidth', () => {
|
||||
const content =
|
||||
'<table><tbody><tr><th colwidth="200">Name</th><th>Description</th></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
// @ts-expect-error content is not guaranteed to be this shape
|
||||
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).toBe(200)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse multiple colWidths', () => {
|
||||
const content =
|
||||
'<table><tbody><tr><th colwidth="200">Name</th><th colspan="3" colwidth="150,100">Description</th></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
// @ts-expect-error content is not guaranteed to be this shape
|
||||
expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).toEqual([150, 100])
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse the colgroup col widths for a header row', () => {
|
||||
const content =
|
||||
'<table><colgroup><col width="64" /><col width="128" /></colgroup><tbody><tr><th>Name</th><th>Description</th></tr><tr><td>Cyndi Lauper</td><td>Singer</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
const headerRow = editor.getJSON().content?.[0].content?.[0]
|
||||
|
||||
expect(headerRow?.content?.[0].attrs?.colwidth).toEqual([64])
|
||||
expect(headerRow?.content?.[1].attrs?.colwidth).toEqual([128])
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
||||
68
packages/extension-table/src/header/table-header.ts
Normal file
68
packages/extension-table/src/header/table-header.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import '../types.js'
|
||||
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
|
||||
import { createAlignAttribute } from '../utils/parseAlign.js'
|
||||
import { parseColwidth } from '../utils/parseColwidth.js'
|
||||
import { fillEmptyCellContent, isEmptyCellElement } from '../utils/fillEmptyCellContent.js'
|
||||
|
||||
export interface TableHeaderOptions {
|
||||
/**
|
||||
* The HTML attributes for a table header node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to create table headers.
|
||||
* @see https://www.tiptap.dev/api/nodes/table-header
|
||||
*/
|
||||
export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
name: 'tableHeader',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
content: 'block+',
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1,
|
||||
},
|
||||
rowspan: {
|
||||
default: 1,
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: parseColwidth,
|
||||
},
|
||||
align: createAlignAttribute(),
|
||||
}
|
||||
},
|
||||
|
||||
tableRole: 'header_cell',
|
||||
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
// Backfill empty cells; non-empty cells fall through to the rule below.
|
||||
tag: 'th',
|
||||
getAttrs: node => (isEmptyCellElement(node) ? {} : false),
|
||||
getContent: (_node, schema) => fillEmptyCellContent(schema.nodes[this.name]),
|
||||
},
|
||||
{ tag: 'th' },
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
},
|
||||
})
|
||||
6
packages/extension-table/src/index.ts
Normal file
6
packages/extension-table/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './cell/index.js'
|
||||
export * from './header/index.js'
|
||||
export * from './kit/index.js'
|
||||
export * from './row/index.js'
|
||||
export * from './table/index.js'
|
||||
export * from './table/TableView.js'
|
||||
64
packages/extension-table/src/kit/index.ts
Normal file
64
packages/extension-table/src/kit/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
import type { TableCellOptions } from '../cell/index.js'
|
||||
import { TableCell } from '../cell/index.js'
|
||||
import type { TableHeaderOptions } from '../header/index.js'
|
||||
import { TableHeader } from '../header/index.js'
|
||||
import type { TableRowOptions } from '../row/index.js'
|
||||
import { TableRow } from '../row/index.js'
|
||||
import type { TableOptions } from '../table/index.js'
|
||||
import { Table } from '../table/index.js'
|
||||
|
||||
export interface TableKitOptions {
|
||||
/**
|
||||
* If set to false, the table extension will not be registered
|
||||
* @example table: false
|
||||
*/
|
||||
table: Partial<TableOptions> | false
|
||||
/**
|
||||
* If set to false, the table extension will not be registered
|
||||
* @example tableCell: false
|
||||
*/
|
||||
tableCell: Partial<TableCellOptions> | false
|
||||
/**
|
||||
* If set to false, the table extension will not be registered
|
||||
* @example tableHeader: false
|
||||
*/
|
||||
tableHeader: Partial<TableHeaderOptions> | false
|
||||
/**
|
||||
* If set to false, the table extension will not be registered
|
||||
* @example tableRow: false
|
||||
*/
|
||||
tableRow: Partial<TableRowOptions> | false
|
||||
}
|
||||
|
||||
/**
|
||||
* The table kit is a collection of table editor extensions.
|
||||
*
|
||||
* It’s a good starting point for building your own table in Tiptap.
|
||||
*/
|
||||
export const TableKit = Extension.create<TableKitOptions>({
|
||||
name: 'tableKit',
|
||||
|
||||
addExtensions() {
|
||||
const extensions = []
|
||||
|
||||
if (this.options.table !== false) {
|
||||
extensions.push(Table.configure(this.options.table))
|
||||
}
|
||||
|
||||
if (this.options.tableCell !== false) {
|
||||
extensions.push(TableCell.configure(this.options.tableCell))
|
||||
}
|
||||
|
||||
if (this.options.tableHeader !== false) {
|
||||
extensions.push(TableHeader.configure(this.options.tableHeader))
|
||||
}
|
||||
|
||||
if (this.options.tableRow !== false) {
|
||||
extensions.push(TableRow.configure(this.options.tableRow))
|
||||
}
|
||||
|
||||
return extensions
|
||||
},
|
||||
})
|
||||
1
packages/extension-table/src/row/index.ts
Normal file
1
packages/extension-table/src/row/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './table-row.js'
|
||||
38
packages/extension-table/src/row/table-row.ts
Normal file
38
packages/extension-table/src/row/table-row.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import '../types.js'
|
||||
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
|
||||
export interface TableRowOptions {
|
||||
/**
|
||||
* The HTML attributes for a table row node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to create table rows.
|
||||
* @see https://www.tiptap.dev/api/nodes/table-row
|
||||
*/
|
||||
export const TableRow = Node.create<TableRowOptions>({
|
||||
name: 'tableRow',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
content: '(tableCell | tableHeader)*',
|
||||
|
||||
tableRole: 'row',
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'tr' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
},
|
||||
})
|
||||
154
packages/extension-table/src/table/TableView.ts
Normal file
154
packages/extension-table/src/table/TableView.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import type { EditorView, NodeView, ViewMutationRecord } from '@tiptap/pm/view'
|
||||
|
||||
import { getColStyleDeclaration } from './utilities/colStyle.js'
|
||||
|
||||
export function updateColumns(
|
||||
node: PMNode,
|
||||
colgroup: HTMLTableColElement, // <colgroup> has the same prototype as <col>
|
||||
table: HTMLTableElement,
|
||||
cellMinWidth: number,
|
||||
overrideCol?: number,
|
||||
overrideValue?: number,
|
||||
) {
|
||||
let totalWidth = 0
|
||||
let fixedWidth = true
|
||||
let nextDOM = colgroup.firstChild
|
||||
const row = node.firstChild
|
||||
|
||||
if (row !== null) {
|
||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||
const { colspan, colwidth } = row.child(i).attrs
|
||||
|
||||
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||
const hasWidth =
|
||||
overrideCol === col ? overrideValue : ((colwidth && colwidth[j]) as number | undefined)
|
||||
const cssWidth = hasWidth ? `${hasWidth}px` : ''
|
||||
|
||||
totalWidth += hasWidth || cellMinWidth
|
||||
|
||||
if (!hasWidth) {
|
||||
fixedWidth = false
|
||||
}
|
||||
|
||||
if (!nextDOM) {
|
||||
const colElement = document.createElement('col')
|
||||
|
||||
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth)
|
||||
|
||||
colElement.style.setProperty(propertyKey, propertyValue)
|
||||
|
||||
colgroup.appendChild(colElement)
|
||||
} else {
|
||||
if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) {
|
||||
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth)
|
||||
|
||||
;(nextDOM as HTMLTableColElement).style.setProperty(propertyKey, propertyValue)
|
||||
}
|
||||
|
||||
nextDOM = nextDOM.nextSibling
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (nextDOM) {
|
||||
const after = nextDOM.nextSibling
|
||||
|
||||
nextDOM.parentNode?.removeChild(nextDOM)
|
||||
nextDOM = after
|
||||
}
|
||||
|
||||
// Check if user has set a width style on the table node
|
||||
const hasUserWidth =
|
||||
node.attrs.style &&
|
||||
typeof node.attrs.style === 'string' &&
|
||||
/\bwidth\s*:/i.test(node.attrs.style)
|
||||
|
||||
if (fixedWidth && !hasUserWidth) {
|
||||
table.style.width = `${totalWidth}px`
|
||||
table.style.minWidth = ''
|
||||
} else {
|
||||
table.style.width = ''
|
||||
table.style.minWidth = `${totalWidth}px`
|
||||
}
|
||||
}
|
||||
|
||||
export class TableView implements NodeView {
|
||||
node: PMNode
|
||||
|
||||
cellMinWidth: number
|
||||
|
||||
dom: HTMLDivElement
|
||||
|
||||
table: HTMLTableElement
|
||||
|
||||
colgroup: HTMLTableColElement
|
||||
|
||||
contentDOM: HTMLTableSectionElement
|
||||
|
||||
constructor(
|
||||
node: PMNode,
|
||||
cellMinWidth: number,
|
||||
_view?: EditorView,
|
||||
HTMLAttributes: Record<string, any> = {},
|
||||
) {
|
||||
this.node = node
|
||||
this.cellMinWidth = cellMinWidth
|
||||
this.dom = document.createElement('div')
|
||||
this.dom.className = 'tableWrapper'
|
||||
this.table = this.dom.appendChild(document.createElement('table'))
|
||||
|
||||
// Apply extension-configured HTMLAttributes to the table element
|
||||
// (e.g. class, data-* set via Table.configure({ HTMLAttributes }))
|
||||
for (const [key, value] of Object.entries(HTMLAttributes)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
if (key === 'style') {
|
||||
this.table.style.cssText = String(value)
|
||||
} else {
|
||||
this.table.setAttribute(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply user styles from the ProseMirror document node.
|
||||
// This may come from HTML parsing (e.g. <table style="...">) and should
|
||||
// override any style set via extension-configured HTMLAttributes above.
|
||||
if (node.attrs.style) {
|
||||
this.table.style.cssText = node.attrs.style
|
||||
}
|
||||
|
||||
this.colgroup = this.table.appendChild(document.createElement('colgroup'))
|
||||
updateColumns(node, this.colgroup, this.table, cellMinWidth)
|
||||
this.contentDOM = this.table.appendChild(document.createElement('tbody'))
|
||||
}
|
||||
|
||||
update(node: PMNode) {
|
||||
if (node.type !== this.node.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.node = node
|
||||
updateColumns(node, this.colgroup, this.table, this.cellMinWidth)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
ignoreMutation(mutation: ViewMutationRecord) {
|
||||
const target = mutation.target as Node
|
||||
const isInsideWrapper = this.dom.contains(target)
|
||||
const isInsideContent = this.contentDOM.contains(target)
|
||||
|
||||
if (isInsideWrapper && !isInsideContent) {
|
||||
if (
|
||||
mutation.type === 'attributes' ||
|
||||
mutation.type === 'childList' ||
|
||||
mutation.type === 'characterData'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
4
packages/extension-table/src/table/index.ts
Normal file
4
packages/extension-table/src/table/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './table.js'
|
||||
export * from './utilities/createColGroup.js'
|
||||
export * from './utilities/createTable.js'
|
||||
export * from './utilities/markdown.js'
|
||||
76
packages/extension-table/src/table/table.test.ts
Normal file
76
packages/extension-table/src/table/table.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { TableCell } from '../cell/table-cell.js'
|
||||
import { TableHeader } from '../header/table-header.js'
|
||||
import { TableRow } from '../row/table-row.js'
|
||||
import { Table } from './table.js'
|
||||
|
||||
describe('Table HTMLAttributes', () => {
|
||||
let editor: Editor | null = null
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
})
|
||||
|
||||
it('applies HTMLAttributes to the <table> element when resizable is false (default)', () => {
|
||||
const editorEl = document.createElement('div')
|
||||
document.body.appendChild(editorEl)
|
||||
|
||||
editor = new Editor({
|
||||
element: editorEl,
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
HTMLAttributes: { class: 'my-custom-table', 'data-test': 'table-test' },
|
||||
resizable: false,
|
||||
}),
|
||||
],
|
||||
})
|
||||
editor.commands.insertTable({ rows: 2, cols: 2, withHeaderRow: false })
|
||||
|
||||
const table = editor.view.dom.querySelector('table')!
|
||||
expect(table.classList.contains('my-custom-table')).toBe(true)
|
||||
expect(table.getAttribute('data-test')).toBe('table-test')
|
||||
|
||||
editorEl.remove()
|
||||
})
|
||||
|
||||
it('applies HTMLAttributes to the <table> element when View is null (renderHTML fallback)', () => {
|
||||
const editorEl = document.createElement('div')
|
||||
document.body.appendChild(editorEl)
|
||||
|
||||
editor = new Editor({
|
||||
element: editorEl,
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
HTMLAttributes: { class: 'fallback-class' },
|
||||
View: null,
|
||||
}),
|
||||
],
|
||||
})
|
||||
editor.commands.insertTable({ rows: 2, cols: 2, withHeaderRow: false })
|
||||
|
||||
const table = editor.view.dom.querySelector('table')!
|
||||
expect(table.classList.contains('fallback-class')).toBe(true)
|
||||
|
||||
editorEl.remove()
|
||||
})
|
||||
})
|
||||
646
packages/extension-table/src/table/table.ts
Normal file
646
packages/extension-table/src/table/table.ts
Normal file
@@ -0,0 +1,646 @@
|
||||
import '../types.js'
|
||||
|
||||
import {
|
||||
type JSONContent,
|
||||
type MarkdownToken,
|
||||
callOrReturn,
|
||||
findParentNodeClosestToPos,
|
||||
getExtensionField,
|
||||
mergeAttributes,
|
||||
Node,
|
||||
} from '@tiptap/core'
|
||||
import type { DOMOutputSpec, Node as PMNode } from '@tiptap/pm/model'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import {
|
||||
addColumnAfter,
|
||||
addColumnBefore,
|
||||
addRowAfter,
|
||||
addRowBefore,
|
||||
CellSelection,
|
||||
columnResizing,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
deleteTable,
|
||||
fixTables,
|
||||
goToNextCell,
|
||||
mergeCells,
|
||||
setCellAttr,
|
||||
splitCell,
|
||||
tableEditing,
|
||||
toggleHeader,
|
||||
toggleHeaderCell,
|
||||
} from '@tiptap/pm/tables'
|
||||
import type { EditorView, NodeView } from '@tiptap/pm/view'
|
||||
|
||||
import { type TableCellAlign, normalizeTableCellAlign } from '../utils/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 { keepCursorInTable } from './utilities/keepCursorInTable.js'
|
||||
import renderTableToMarkdown, { preprocessTablePipes } from './utilities/markdown.js'
|
||||
|
||||
type MarkdownTableToken = {
|
||||
align?: Array<TableCellAlign | null>
|
||||
header?: { tokens: MarkdownToken[]; align?: TableCellAlign | null }[]
|
||||
rows?: { tokens: MarkdownToken[]; align?: TableCellAlign | null }[][]
|
||||
} & MarkdownToken
|
||||
|
||||
export interface TableOptions {
|
||||
/**
|
||||
* HTML attributes for the table element.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>
|
||||
|
||||
/**
|
||||
* Enables the resizing of tables.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
resizable: boolean
|
||||
|
||||
/**
|
||||
* Controls whether the table should be wrapped in a div with class "tableWrapper" when rendered.
|
||||
* In editable mode with resizable tables, this wrapper is always present via TableView.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
renderWrapper: boolean
|
||||
|
||||
/**
|
||||
* The width of the resize handle.
|
||||
* @default 5
|
||||
* @example 10
|
||||
*/
|
||||
handleWidth: number
|
||||
|
||||
/**
|
||||
* The minimum width of a cell.
|
||||
* @default 25
|
||||
* @example 50
|
||||
*/
|
||||
cellMinWidth: number
|
||||
|
||||
/**
|
||||
* The node view to render the table.
|
||||
* @default TableView
|
||||
*/
|
||||
View:
|
||||
| (new (
|
||||
node: PMNode,
|
||||
cellMinWidth: number,
|
||||
view: EditorView,
|
||||
HTMLAttributes?: Record<string, any>,
|
||||
) => NodeView)
|
||||
| null
|
||||
|
||||
/**
|
||||
* Enables the resizing of the last column.
|
||||
* @default true
|
||||
* @example false
|
||||
*/
|
||||
lastColumnResizable: boolean
|
||||
|
||||
/**
|
||||
* Allow table node selection.
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
allowTableNodeSelection: boolean
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
table: {
|
||||
/**
|
||||
* Insert a table
|
||||
* @param options The table attributes
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
*/
|
||||
insertTable: (options?: {
|
||||
rows?: number
|
||||
cols?: number
|
||||
withHeaderRow?: boolean
|
||||
}) => ReturnType
|
||||
|
||||
/**
|
||||
* Add a column before the current column
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.addColumnBefore()
|
||||
*/
|
||||
addColumnBefore: () => ReturnType
|
||||
|
||||
/**
|
||||
* Add a column after the current column
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.addColumnAfter()
|
||||
*/
|
||||
addColumnAfter: () => ReturnType
|
||||
|
||||
/**
|
||||
* Delete the current column
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.deleteColumn()
|
||||
*/
|
||||
deleteColumn: () => ReturnType
|
||||
|
||||
/**
|
||||
* Add a row before the current row
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.addRowBefore()
|
||||
*/
|
||||
addRowBefore: () => ReturnType
|
||||
|
||||
/**
|
||||
* Add a row after the current row
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.addRowAfter()
|
||||
*/
|
||||
addRowAfter: () => ReturnType
|
||||
|
||||
/**
|
||||
* Delete the current row
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.deleteRow()
|
||||
*/
|
||||
deleteRow: () => ReturnType
|
||||
|
||||
/**
|
||||
* Delete the current table
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.deleteTable()
|
||||
*/
|
||||
deleteTable: () => ReturnType
|
||||
|
||||
/**
|
||||
* Merge the currently selected cells
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.mergeCells()
|
||||
*/
|
||||
mergeCells: () => ReturnType
|
||||
|
||||
/**
|
||||
* Split the currently selected cell
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.splitCell()
|
||||
*/
|
||||
splitCell: () => ReturnType
|
||||
|
||||
/**
|
||||
* Toggle the header column
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.toggleHeaderColumn()
|
||||
*/
|
||||
toggleHeaderColumn: () => ReturnType
|
||||
|
||||
/**
|
||||
* Toggle the header row
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.toggleHeaderRow()
|
||||
*/
|
||||
toggleHeaderRow: () => ReturnType
|
||||
|
||||
/**
|
||||
* Toggle the header cell
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.toggleHeaderCell()
|
||||
*/
|
||||
toggleHeaderCell: () => ReturnType
|
||||
|
||||
/**
|
||||
* Merge or split the currently selected cells
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.mergeOrSplit()
|
||||
*/
|
||||
mergeOrSplit: () => ReturnType
|
||||
|
||||
/**
|
||||
* Set a cell attribute
|
||||
* @param name The attribute name
|
||||
* @param value The attribute value
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.setCellAttribute('align', 'right')
|
||||
*/
|
||||
setCellAttribute: (name: string, value: any) => ReturnType
|
||||
|
||||
/**
|
||||
* Moves the selection to the next cell
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.goToNextCell()
|
||||
*/
|
||||
goToNextCell: () => ReturnType
|
||||
|
||||
/**
|
||||
* Moves the selection to the previous cell
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.goToPreviousCell()
|
||||
*/
|
||||
goToPreviousCell: () => ReturnType
|
||||
|
||||
/**
|
||||
* Try to fix the table structure if necessary
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.fixTables()
|
||||
*/
|
||||
fixTables: () => ReturnType
|
||||
|
||||
/**
|
||||
* Set a cell selection inside the current table
|
||||
* @param position The cell position
|
||||
* @returns True if the command was successful, otherwise false
|
||||
* @example editor.commands.setCellSelection({ anchorCell: 1, headCell: 2 })
|
||||
*/
|
||||
setCellSelection: (position: { anchorCell: number; headCell?: number }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to create tables.
|
||||
* @see https://www.tiptap.dev/api/nodes/table
|
||||
*/
|
||||
export const Table = Node.create<TableOptions>({
|
||||
name: 'table',
|
||||
|
||||
// @ts-ignore
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
resizable: false,
|
||||
renderWrapper: false,
|
||||
handleWidth: 5,
|
||||
cellMinWidth: 25,
|
||||
// TODO: fix
|
||||
View: TableView,
|
||||
lastColumnResizable: true,
|
||||
allowTableNodeSelection: false,
|
||||
}
|
||||
},
|
||||
|
||||
content: 'tableRow+',
|
||||
|
||||
tableRole: 'table',
|
||||
|
||||
isolating: true,
|
||||
|
||||
group: 'block',
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'table' }]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const { colgroup, tableWidth, tableMinWidth } = createColGroup(node, this.options.cellMinWidth)
|
||||
|
||||
const userStyles = HTMLAttributes.style as string | undefined
|
||||
|
||||
function getTableStyle() {
|
||||
if (userStyles) {
|
||||
return userStyles
|
||||
}
|
||||
|
||||
return tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}`
|
||||
}
|
||||
|
||||
const table: DOMOutputSpec = [
|
||||
'table',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: getTableStyle(),
|
||||
}),
|
||||
colgroup,
|
||||
['tbody', 0],
|
||||
]
|
||||
|
||||
return this.options.renderWrapper ? ['div', { class: 'tableWrapper' }, table] : table
|
||||
},
|
||||
|
||||
parseMarkdown: (token: MarkdownTableToken, h) => {
|
||||
const rows = []
|
||||
const alignments = Array.isArray(token.align) ? token.align : []
|
||||
|
||||
if (token.header) {
|
||||
const headerCells: JSONContent[] = []
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
if (token.rows) {
|
||||
token.rows.forEach(row => {
|
||||
const bodyCells: JSONContent[] = []
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
return h.createNode('table', undefined, rows)
|
||||
},
|
||||
|
||||
renderMarkdown: (node, h) => {
|
||||
return renderTableToMarkdown(node, h)
|
||||
},
|
||||
|
||||
markdownTokenizer: {
|
||||
name: 'table',
|
||||
level: 'block' as const,
|
||||
start: (src: string) => {
|
||||
const lines = src.split('\n')
|
||||
if (lines.length < 2) return -1
|
||||
const sep = lines[1]
|
||||
if (!/^[ \t|:]*-[ \t|:-]*$/.test(sep) || !sep.includes('|')) return -1
|
||||
return lines[0].includes('|') ? 0 : -1
|
||||
},
|
||||
tokenize(src, _tokens, helper) {
|
||||
// Marked terminates a table block at a blank line. Slicing to the first
|
||||
// \n\n keeps helper.blockTokens isolated to the candidate table so it
|
||||
// cannot consume content that follows. When no blank line exists the
|
||||
// full remaining source is used and marked's own table regex determines
|
||||
// the boundary (it excludes headings, fences, blockquotes, etc.).
|
||||
const blankLineIndex = src.indexOf('\n\n')
|
||||
const candidate = blankLineIndex >= 0 ? src.slice(0, blankLineIndex) : src
|
||||
|
||||
// A GFM table needs at least a header and a separator row (two lines).
|
||||
const candidateLines = candidate.split('\n')
|
||||
if (candidateLines.length < 2) return undefined
|
||||
|
||||
// Guard: the second line must match a GFM delimiter row (dashes, colons,
|
||||
// pipes, spaces) AND must contain at least one '|'. A bare '---' matches
|
||||
// the dash pattern but is a setext heading marker, not a table separator.
|
||||
// Calling helper.blockTokens on non-table content contaminates the outer
|
||||
// lexer's inline queue with preprocessed (backslash-modified) content.
|
||||
const sep = candidateLines[1]
|
||||
if (!/^[ \t|:]*-[ \t|:-]*$/.test(sep) || !sep.includes('|')) return undefined
|
||||
|
||||
const preprocessed = preprocessTablePipes(candidate)
|
||||
|
||||
// Nothing to fix — let marked's built-in handle it.
|
||||
if (preprocessed === candidate) return undefined
|
||||
|
||||
// Re-lex only the preprocessed candidate. The recursive call to our
|
||||
// tokenizer finds no unescaped pipes left inside code spans and returns
|
||||
// undefined, so marked's built-in table tokenizer produces
|
||||
// correctly-split cells.
|
||||
const block = helper.blockTokens(preprocessed)
|
||||
const tableToken = block[0]
|
||||
if (tableToken?.type !== 'table' || !tableToken.raw) return undefined
|
||||
|
||||
// preprocessTablePipes only inserts backslashes (never adds or removes
|
||||
// newlines), so line N in preprocessed maps 1-to-1 to line N in src.
|
||||
// Use the token's line count to reconstruct raw from the original src so
|
||||
// marked advances its cursor by the correct amount.
|
||||
const lineCount = tableToken.raw.split('\n').length
|
||||
const raw = src.split('\n').slice(0, lineCount).join('\n')
|
||||
|
||||
return { ...tableToken, raw }
|
||||
},
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow)
|
||||
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.from + 1
|
||||
|
||||
tr.replaceSelectionWith(node)
|
||||
.scrollIntoView()
|
||||
.setSelection(TextSelection.near(tr.doc.resolve(offset)))
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
addColumnBefore:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return addColumnBefore(state, dispatch)
|
||||
},
|
||||
addColumnAfter:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return addColumnAfter(state, dispatch)
|
||||
},
|
||||
deleteColumn:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
const table = findParentNodeClosestToPos(
|
||||
state.selection.$from,
|
||||
node => node.type.name === 'table',
|
||||
)
|
||||
|
||||
return deleteColumn(
|
||||
state,
|
||||
dispatch &&
|
||||
(tr => {
|
||||
if (table) {
|
||||
keepCursorInTable(tr, table.pos)
|
||||
}
|
||||
|
||||
dispatch(tr)
|
||||
}),
|
||||
)
|
||||
},
|
||||
addRowBefore:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return addRowBefore(state, dispatch)
|
||||
},
|
||||
addRowAfter:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return addRowAfter(state, dispatch)
|
||||
},
|
||||
deleteRow:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
const table = findParentNodeClosestToPos(
|
||||
state.selection.$from,
|
||||
node => node.type.name === 'table',
|
||||
)
|
||||
|
||||
return deleteRow(
|
||||
state,
|
||||
dispatch &&
|
||||
(tr => {
|
||||
if (table) {
|
||||
keepCursorInTable(tr, table.pos)
|
||||
}
|
||||
|
||||
dispatch(tr)
|
||||
}),
|
||||
)
|
||||
},
|
||||
deleteTable:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return deleteTable(state, dispatch)
|
||||
},
|
||||
mergeCells:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return mergeCells(state, dispatch)
|
||||
},
|
||||
splitCell:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return splitCell(state, dispatch)
|
||||
},
|
||||
toggleHeaderColumn:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return toggleHeader('column')(state, dispatch)
|
||||
},
|
||||
toggleHeaderRow:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return toggleHeader('row')(state, dispatch)
|
||||
},
|
||||
toggleHeaderCell:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return toggleHeaderCell(state, dispatch)
|
||||
},
|
||||
mergeOrSplit:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
if (mergeCells(state, dispatch)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return splitCell(state, dispatch)
|
||||
},
|
||||
setCellAttribute:
|
||||
(name, value) =>
|
||||
({ state, dispatch }) => {
|
||||
return setCellAttr(name, value)(state, dispatch)
|
||||
},
|
||||
goToNextCell:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return goToNextCell(1)(state, dispatch)
|
||||
},
|
||||
goToPreviousCell:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
return goToNextCell(-1)(state, dispatch)
|
||||
},
|
||||
fixTables:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
if (dispatch) {
|
||||
fixTables(state)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
setCellSelection:
|
||||
position =>
|
||||
({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell)
|
||||
|
||||
// @ts-ignore
|
||||
tr.setSelection(selection)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.commands.goToNextCell()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!this.editor.can().addRowAfter()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.editor.chain().addRowAfter().goToNextCell().run()
|
||||
},
|
||||
'Shift-Tab': () => this.editor.commands.goToPreviousCell(),
|
||||
Backspace: deleteTableWhenAllCellsSelected,
|
||||
'Mod-Backspace': deleteTableWhenAllCellsSelected,
|
||||
Delete: deleteTableWhenAllCellsSelected,
|
||||
'Mod-Delete': deleteTableWhenAllCellsSelected,
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const isResizable = this.options.resizable && this.editor.isEditable
|
||||
|
||||
return [
|
||||
...(isResizable
|
||||
? [
|
||||
columnResizing({
|
||||
handleWidth: this.options.handleWidth,
|
||||
cellMinWidth: this.options.cellMinWidth,
|
||||
defaultCellMinWidth: this.options.cellMinWidth,
|
||||
View: this.options.View,
|
||||
lastColumnResizable: this.options.lastColumnResizable,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
tableEditing({
|
||||
allowTableNodeSelection: this.options.allowTableNodeSelection,
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
// When resizable, the columnResizing plugin registers its own NodeView.
|
||||
// We only register one here for the non-resizable case so that
|
||||
// <colgroup> stays in sync with column changes (issue #7015).
|
||||
const isResizable = this.options.resizable && this.editor.isEditable
|
||||
const View = this.options.View
|
||||
|
||||
if (isResizable || !View) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ({ node, view, HTMLAttributes }) => {
|
||||
const mergedAttributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
|
||||
|
||||
return new View(node, this.options.cellMinWidth, view, mergedAttributes) as NodeView
|
||||
}
|
||||
},
|
||||
|
||||
extendNodeSchema(extension) {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
}
|
||||
|
||||
return {
|
||||
tableRole: callOrReturn(getExtensionField(extension, 'tableRole', context)),
|
||||
}
|
||||
},
|
||||
})
|
||||
12
packages/extension-table/src/table/utilities/colStyle.ts
Normal file
12
packages/extension-table/src/table/utilities/colStyle.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function getColStyleDeclaration(
|
||||
minWidth: number,
|
||||
width: number | undefined,
|
||||
): [string, string] {
|
||||
if (width) {
|
||||
// apply the stored width unless it is below the configured minimum cell width
|
||||
return ['width', `${Math.max(width, minWidth)}px`]
|
||||
}
|
||||
|
||||
// set the minimum with on the column if it has no stored width
|
||||
return ['min-width', `${minWidth}px`]
|
||||
}
|
||||
12
packages/extension-table/src/table/utilities/createCell.ts
Normal file
12
packages/extension-table/src/table/utilities/createCell.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Fragment, Node as ProsemirrorNode, NodeType } from '@tiptap/pm/model'
|
||||
|
||||
export function createCell(
|
||||
cellType: NodeType,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
|
||||
): ProsemirrorNode | null | undefined {
|
||||
if (cellContent) {
|
||||
return cellType.createChecked(null, cellContent)
|
||||
}
|
||||
|
||||
return cellType.createAndFill()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { DOMOutputSpec, Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
import { getColStyleDeclaration } from './colStyle.js'
|
||||
|
||||
export type ColGroup =
|
||||
| {
|
||||
colgroup: DOMOutputSpec
|
||||
tableWidth: string
|
||||
tableMinWidth: string
|
||||
}
|
||||
| Record<string, never>
|
||||
|
||||
/**
|
||||
* Creates a colgroup element for a table node in ProseMirror.
|
||||
*
|
||||
* @param node - The ProseMirror node representing the table.
|
||||
* @param cellMinWidth - The minimum width of a cell in the table.
|
||||
* @param overrideCol - (Optional) The index of the column to override the width of.
|
||||
* @param overrideValue - (Optional) The width value to use for the overridden column.
|
||||
* @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table.
|
||||
*/
|
||||
export function createColGroup(node: PMNode, cellMinWidth: number): ColGroup
|
||||
export function createColGroup(
|
||||
node: PMNode,
|
||||
cellMinWidth: number,
|
||||
overrideCol: number,
|
||||
overrideValue: number,
|
||||
): ColGroup
|
||||
export function createColGroup(
|
||||
node: PMNode,
|
||||
cellMinWidth: number,
|
||||
overrideCol?: number,
|
||||
overrideValue?: number,
|
||||
): ColGroup {
|
||||
let totalWidth = 0
|
||||
let fixedWidth = true
|
||||
const cols: DOMOutputSpec[] = []
|
||||
const row = node.firstChild
|
||||
|
||||
if (!row) {
|
||||
return {}
|
||||
}
|
||||
|
||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||
const { colspan, colwidth } = row.child(i).attrs
|
||||
|
||||
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||
const hasWidth =
|
||||
overrideCol === col ? overrideValue : colwidth && (colwidth[j] as number | undefined)
|
||||
|
||||
totalWidth += hasWidth || cellMinWidth
|
||||
|
||||
if (!hasWidth) {
|
||||
fixedWidth = false
|
||||
}
|
||||
|
||||
const [property, value] = getColStyleDeclaration(cellMinWidth, hasWidth)
|
||||
|
||||
cols.push(['col', { style: `${property}: ${value}` }])
|
||||
}
|
||||
}
|
||||
|
||||
const tableWidth = fixedWidth ? `${totalWidth}px` : ''
|
||||
const tableMinWidth = fixedWidth ? '' : `${totalWidth}px`
|
||||
|
||||
const colgroup: DOMOutputSpec = ['colgroup', {}, ...cols]
|
||||
|
||||
return { colgroup, tableWidth, tableMinWidth }
|
||||
}
|
||||
40
packages/extension-table/src/table/utilities/createTable.ts
Normal file
40
packages/extension-table/src/table/utilities/createTable.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Fragment, Node as ProsemirrorNode, Schema } from '@tiptap/pm/model'
|
||||
|
||||
import { createCell } from './createCell.js'
|
||||
import { getTableNodeTypes } from './getTableNodeTypes.js'
|
||||
|
||||
export function createTable(
|
||||
schema: Schema,
|
||||
rowsCount: number,
|
||||
colsCount: number,
|
||||
withHeaderRow: boolean,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
|
||||
): ProsemirrorNode {
|
||||
const types = getTableNodeTypes(schema)
|
||||
const headerCells: ProsemirrorNode[] = []
|
||||
const cells: ProsemirrorNode[] = []
|
||||
|
||||
for (let index = 0; index < colsCount; index += 1) {
|
||||
const cell = createCell(types.cell, cellContent)
|
||||
|
||||
if (cell) {
|
||||
cells.push(cell)
|
||||
}
|
||||
|
||||
if (withHeaderRow) {
|
||||
const headerCell = createCell(types.header_cell, cellContent)
|
||||
|
||||
if (headerCell) {
|
||||
headerCells.push(headerCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows: ProsemirrorNode[] = []
|
||||
|
||||
for (let index = 0; index < rowsCount; index += 1) {
|
||||
rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells))
|
||||
}
|
||||
|
||||
return types.table.createChecked(null, rows)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { KeyboardShortcutCommand } from '@tiptap/core'
|
||||
import { findParentNodeClosestToPos } from '@tiptap/core'
|
||||
|
||||
import { isCellSelection } from './isCellSelection.js'
|
||||
|
||||
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => {
|
||||
const { selection } = editor.state
|
||||
|
||||
if (!isCellSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
let cellCount = 0
|
||||
const table = findParentNodeClosestToPos(selection.ranges[0].$from, node => {
|
||||
return node.type.name === 'table'
|
||||
})
|
||||
|
||||
table?.node.descendants(node => {
|
||||
if (node.type.name === 'table') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (['tableCell', 'tableHeader'].includes(node.type.name)) {
|
||||
cellCount += 1
|
||||
}
|
||||
})
|
||||
|
||||
const allCellsSelected = cellCount === selection.ranges.length
|
||||
|
||||
if (!allCellsSelected) {
|
||||
return false
|
||||
}
|
||||
|
||||
editor.commands.deleteTable()
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NodeType, Schema } from '@tiptap/pm/model'
|
||||
|
||||
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
|
||||
if (schema.cached.tableNodeTypes) {
|
||||
return schema.cached.tableNodeTypes
|
||||
}
|
||||
|
||||
const roles: { [key: string]: NodeType } = {}
|
||||
|
||||
Object.keys(schema.nodes).forEach(type => {
|
||||
const nodeType = schema.nodes[type]
|
||||
|
||||
if (nodeType.spec.tableRole) {
|
||||
roles[nodeType.spec.tableRole] = nodeType
|
||||
}
|
||||
})
|
||||
|
||||
schema.cached.tableNodeTypes = roles
|
||||
|
||||
return roles
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CellSelection } from '@tiptap/pm/tables'
|
||||
|
||||
export function isCellSelection(value: unknown): value is CellSelection {
|
||||
return value instanceof CellSelection
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { findParentNodeClosestToPos } from '@tiptap/core'
|
||||
import { TextSelection, type Transaction } from '@tiptap/pm/state'
|
||||
|
||||
// prosemirror-tables does not move the selection after a deletion, so removing the
|
||||
// last row or column can push the cursor out of the table into the content below.
|
||||
export function keepCursorInTable(tr: Transaction, tablePos: number) {
|
||||
const mappedTablePos = tr.mapping.map(tablePos)
|
||||
|
||||
// The delete can push the cursor into a following table, so compare the
|
||||
// mapped position to confirm it is still the table we edited.
|
||||
const stillInTable = findParentNodeClosestToPos(
|
||||
tr.selection.$from,
|
||||
node => node.type.name === 'table',
|
||||
)
|
||||
|
||||
if (stillInTable?.pos === mappedTablePos) {
|
||||
return
|
||||
}
|
||||
|
||||
const tableNode = tr.doc.nodeAt(mappedTablePos)
|
||||
|
||||
if (!tableNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const endOfTable = mappedTablePos + tableNode.nodeSize - 1
|
||||
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(endOfTable), -1))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { escapeTableCellPipes } from './markdown.js'
|
||||
|
||||
describe('escapeTableCellPipes', () => {
|
||||
it('escapes a bare pipe inside a code span', () => {
|
||||
expect(escapeTableCellPipes('| `a|b` |')).toBe('| `a\\|b` |')
|
||||
})
|
||||
|
||||
it('escapes every pipe of a consecutive run', () => {
|
||||
expect(escapeTableCellPipes('| `||` |')).toBe('| `\\|\\|` |')
|
||||
})
|
||||
|
||||
it('keeps an already-escaped pipe untouched', () => {
|
||||
expect(escapeTableCellPipes('| `a\\|b` |')).toBe('| `a\\|b` |')
|
||||
})
|
||||
|
||||
it('keeps a pipe preceded by a backslash pair untouched', () => {
|
||||
expect(escapeTableCellPipes('| `a\\\\|b` |')).toBe('| `a\\\\|b` |')
|
||||
})
|
||||
|
||||
it('handles mixed escaped and bare pipes in one span', () => {
|
||||
expect(escapeTableCellPipes('| `|\\||` |')).toBe('| `\\|\\|\\|` |')
|
||||
})
|
||||
})
|
||||
223
packages/extension-table/src/table/utilities/markdown.ts
Normal file
223
packages/extension-table/src/table/utilities/markdown.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/core'
|
||||
|
||||
import {
|
||||
type TableCellAlign as TableCellAlignType,
|
||||
normalizeTableCellAlignFromAttributes,
|
||||
TableCellAlign,
|
||||
} from '../../utils/parseAlign.js'
|
||||
|
||||
export const DEFAULT_CELL_LINE_SEPARATOR = '\u001F'
|
||||
|
||||
/**
|
||||
* Walk a single table-row line and escape any `|` characters that appear
|
||||
* inside backtick code spans so marked's cell splitter ignores them.
|
||||
* Backslash escape sequences outside code spans are passed through untouched.
|
||||
* If a backtick run has no matching closer on the same line it is emitted
|
||||
* as-is — no over-escaping for malformed input.
|
||||
*/
|
||||
export function escapeTableCellPipes(line: string): string {
|
||||
let result = ''
|
||||
let i = 0
|
||||
while (i < line.length) {
|
||||
if (line[i] === '\\' && i + 1 < line.length) {
|
||||
result += line[i] + line[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (line[i] !== '`') {
|
||||
result += line[i++]
|
||||
continue
|
||||
}
|
||||
let runLen = 0
|
||||
while (i + runLen < line.length && line[i + runLen] === '`') runLen += 1
|
||||
let j = i + runLen
|
||||
let found = false
|
||||
while (j < line.length) {
|
||||
if (line[j] !== '`') {
|
||||
j += 1
|
||||
continue
|
||||
}
|
||||
let closeLen = 0
|
||||
while (j + closeLen < line.length && line[j + closeLen] === '`') closeLen += 1
|
||||
if (closeLen === runLen) {
|
||||
const spanContent = line.slice(i + runLen, j)
|
||||
// Matches an already-escaped pipe (`\|`, kept as-is) or a bare pipe
|
||||
// (escaped). Deliberately avoids a negative lookbehind assertion:
|
||||
// WebKit before Safari 16.4 cannot parse lookbehind, and a regex
|
||||
// literal fails the whole chunk at parse time, not at call time.
|
||||
result +=
|
||||
line.slice(i, i + runLen) +
|
||||
spanContent.replace(/\\\||\|/g, match => (match === '|' ? '\\|' : match)) +
|
||||
line.slice(j, j + runLen)
|
||||
i = j + runLen
|
||||
found = true
|
||||
break
|
||||
}
|
||||
j += closeLen
|
||||
}
|
||||
if (!found) {
|
||||
result += line.slice(i, i + runLen)
|
||||
i += runLen
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape pipe characters inside backtick code spans on table-row lines.
|
||||
* marked's `splitCells` only recognises backslash-escaped pipes (`\|`) and
|
||||
* splits on every other `|`, so \`a || b\` in a table cell would be treated
|
||||
* as multiple column delimiters. Escaping them here lets `splitCells` skip
|
||||
* them; it already converts `\|` → `|` after splitting, so the cell content
|
||||
* is restored correctly.
|
||||
*/
|
||||
export function preprocessTablePipes(src: string): string {
|
||||
return src
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (!line.includes('|') || !line.includes('`')) return line
|
||||
return escapeTableCellPipes(line)
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function collapseWhitespace(s: string) {
|
||||
return (s || '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function renderTableToMarkdown(
|
||||
node: JSONContent,
|
||||
h: MarkdownRendererHelpers,
|
||||
options: { cellLineSeparator?: string } = {},
|
||||
) {
|
||||
const cellSep = options.cellLineSeparator ?? DEFAULT_CELL_LINE_SEPARATOR
|
||||
|
||||
if (!node || !node.content || node.content.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 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; align: TableCellAlignType | null }[] = []
|
||||
|
||||
if (rowNode.content) {
|
||||
rowNode.content.forEach(cellNode => {
|
||||
let raw = ''
|
||||
|
||||
if (cellNode.content && Array.isArray(cellNode.content) && cellNode.content.length > 1) {
|
||||
// Render each direct child separately and join with separator so we can split again later
|
||||
const parts = cellNode.content.map(child =>
|
||||
h.renderChildren(child as unknown as JSONContent),
|
||||
)
|
||||
raw = parts.join(cellSep)
|
||||
} else {
|
||||
raw = cellNode.content
|
||||
? h.renderChildren(cellNode.content as unknown as JSONContent[])
|
||||
: ''
|
||||
}
|
||||
|
||||
// Cells have to stay on a single line, so line breaks become <br> tags.
|
||||
// The parser already turns <br> back into hard breaks, so this round trips.
|
||||
const text = collapseWhitespace(
|
||||
raw
|
||||
.split(cellSep)
|
||||
.join('\n')
|
||||
.replace(/[ \t]*\r?\n[ \t]*/g, '<br>'),
|
||||
)
|
||||
const isHeader = cellNode.type === 'tableHeader'
|
||||
const align = normalizeTableCellAlignFromAttributes(cellNode.attrs)
|
||||
|
||||
cells.push({ text, isHeader, align })
|
||||
})
|
||||
}
|
||||
|
||||
rows.push(cells)
|
||||
})
|
||||
|
||||
const columnCount = rows.reduce((max, r) => Math.max(max, r.length), 0)
|
||||
|
||||
if (columnCount === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Compute max width for each column
|
||||
const colWidths = Array.from<number>({ length: columnCount }).fill(0)
|
||||
|
||||
rows.forEach(r => {
|
||||
for (let i = 0; i < columnCount; i += 1) {
|
||||
const cell = r[i]?.text || ''
|
||||
const len = cell.length
|
||||
if (len > colWidths[i]) {
|
||||
colWidths[i] = len
|
||||
}
|
||||
|
||||
if (colWidths[i] < 3) {
|
||||
colWidths[i] = 3
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const pad = (s: string, width: number) => s + ' '.repeat(Math.max(0, width - s.length))
|
||||
|
||||
const headerRow = rows[0]
|
||||
const hasHeader = headerRow.some(c => c.isHeader)
|
||||
const colAlignments: Array<TableCellAlignType | null> = Array.from<TableCellAlignType | null>({
|
||||
length: 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'
|
||||
|
||||
// Render header: if the document has a header row (tableHeader cells) use it,
|
||||
// otherwise emit an empty header row so most Markdown parsers will recognize
|
||||
// the table (this makes roundtripping to Markdown -> JSON more reliable).
|
||||
const headerTexts = Array.from<number>({ length: columnCount }).map((_, i) =>
|
||||
hasHeader ? (headerRow[i] && headerRow[i].text) || '' : '',
|
||||
)
|
||||
|
||||
out += `| ${headerTexts.map((t, i) => pad(t, colWidths[i])).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
|
||||
body.forEach(r => {
|
||||
out += `| ${Array.from<number>({ length: columnCount })
|
||||
.fill(0)
|
||||
.map((_, i) => pad((r[i] && r[i].text) || '', colWidths[i]))
|
||||
.join(' | ')} |\n`
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export default renderTableToMarkdown
|
||||
19
packages/extension-table/src/types.ts
Normal file
19
packages/extension-table/src/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ParentConfig } from '@tiptap/core'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface NodeConfig<Options, Storage> {
|
||||
/**
|
||||
* A string or function to determine the role of the table.
|
||||
* @default 'table'
|
||||
* @example () => 'table'
|
||||
*/
|
||||
tableRole?:
|
||||
| string
|
||||
| ((this: {
|
||||
name: string
|
||||
options: Options
|
||||
storage: Storage
|
||||
parent: ParentConfig<NodeConfig<Options>>['tableRole']
|
||||
}) => string)
|
||||
}
|
||||
}
|
||||
25
packages/extension-table/src/utils/fillEmptyCellContent.ts
Normal file
25
packages/extension-table/src/utils/fillEmptyCellContent.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { NodeType } from '@tiptap/pm/model'
|
||||
import { Fragment } from '@tiptap/pm/model'
|
||||
|
||||
// ASCII whitespace only, so a non-breaking-space cell is not treated as empty.
|
||||
const COLLAPSIBLE_WHITESPACE = /[ \t\r\n\f]+/g
|
||||
|
||||
/** Whether a cell/header element has no child elements and only collapsible whitespace. */
|
||||
export function isEmptyCellElement(element: HTMLElement): boolean {
|
||||
if (element.children.length > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (element.textContent ?? '').replace(COLLAPSIBLE_WHITESPACE, '') === ''
|
||||
}
|
||||
|
||||
/** Builds a cell/header's minimal `block+` content, derived from the node type. */
|
||||
export function fillEmptyCellContent(cellType: NodeType): Fragment {
|
||||
const filled = cellType.createAndFill()
|
||||
|
||||
if (!filled) {
|
||||
throw new Error(`[tiptap error]: "${cellType.name}" has no default content to backfill.`)
|
||||
}
|
||||
|
||||
return filled.content
|
||||
}
|
||||
78
packages/extension-table/src/utils/parseAlign.ts
Normal file
78
packages/extension-table/src/utils/parseAlign.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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}`,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
37
packages/extension-table/src/utils/parseColwidth.ts
Normal file
37
packages/extension-table/src/utils/parseColwidth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* reads the width of the `<col>` element matching a cell's column from the table's `<colgroup>`
|
||||
*
|
||||
* @param element - The table cell/header DOM element
|
||||
* @returns - An array with the column width in pixels or null
|
||||
*/
|
||||
function parseColgroupWidth(element: HTMLElement): number[] | null {
|
||||
const row = element.parentElement
|
||||
const table = element.closest('table')
|
||||
|
||||
if (!row || !table) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cellIndex = Array.from(row.children).indexOf(element)
|
||||
const width = table.querySelectorAll('colgroup > col')[cellIndex]?.getAttribute('width')
|
||||
|
||||
return width ? [parseInt(width, 10)] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the column width/s of a table cell/header from an HTML element.
|
||||
* it prefers the `colwidth` attribute and if not-provided falls back to the `width` attribute
|
||||
* of the matching `<col>` element in the table's `<colgroup>`.
|
||||
*
|
||||
* @param element - The table cell/header DOM element
|
||||
* @returns - An array of column widths in pixels or null
|
||||
*/
|
||||
export function parseColwidth(element: HTMLElement): number[] | null {
|
||||
const colwidth = element.getAttribute('colwidth')
|
||||
|
||||
if (colwidth) {
|
||||
return colwidth.split(',').map(width => parseInt(width, 10))
|
||||
}
|
||||
|
||||
return parseColgroupWidth(element)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
describe('colgroup updates after column commands', () => {
|
||||
const editorElClass = 'tiptap'
|
||||
let editor: Editor | null = null
|
||||
|
||||
const createEditorEl = () => {
|
||||
const editorEl = document.createElement('div')
|
||||
|
||||
editorEl.classList.add(editorElClass)
|
||||
document.body.appendChild(editorEl)
|
||||
return editorEl
|
||||
}
|
||||
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
|
||||
|
||||
const createEditor = (resizable: boolean) =>
|
||||
new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({ resizable }),
|
||||
],
|
||||
})
|
||||
|
||||
const countCols = (e: Editor) => e.view.dom.querySelectorAll('colgroup > col').length
|
||||
|
||||
const countCellsInFirstRow = (e: Editor) => {
|
||||
const firstRow = e.view.dom.querySelector('tbody > tr')
|
||||
|
||||
return firstRow ? firstRow.querySelectorAll('td, th').length : 0
|
||||
}
|
||||
|
||||
// Place cursor inside the first cell of the table so column commands have a target
|
||||
const focusFirstCell = (e: Editor) => {
|
||||
e.commands.focus()
|
||||
e.commands.setTextSelection(3)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
describe('resizable: false (default — bug case)', () => {
|
||||
it('updates <colgroup> when a column is deleted', () => {
|
||||
editor = createEditor(false)
|
||||
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
|
||||
expect(countCols(editor)).toBe(3)
|
||||
expect(countCellsInFirstRow(editor)).toBe(3)
|
||||
|
||||
focusFirstCell(editor)
|
||||
editor.commands.deleteColumn()
|
||||
|
||||
expect(countCellsInFirstRow(editor)).toBe(2)
|
||||
expect(countCols(editor)).toBe(2)
|
||||
})
|
||||
|
||||
it('updates <colgroup> when a column is added after', () => {
|
||||
editor = createEditor(false)
|
||||
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
|
||||
expect(countCols(editor)).toBe(3)
|
||||
|
||||
focusFirstCell(editor)
|
||||
editor.commands.addColumnAfter()
|
||||
|
||||
expect(countCellsInFirstRow(editor)).toBe(4)
|
||||
expect(countCols(editor)).toBe(4)
|
||||
})
|
||||
|
||||
it('updates <colgroup> when a column is added before', () => {
|
||||
editor = createEditor(false)
|
||||
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
|
||||
expect(countCols(editor)).toBe(3)
|
||||
|
||||
focusFirstCell(editor)
|
||||
editor.commands.addColumnBefore()
|
||||
|
||||
expect(countCellsInFirstRow(editor)).toBe(4)
|
||||
expect(countCols(editor)).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resizable: true (control — should already work)', () => {
|
||||
it('updates <colgroup> when a column is deleted', () => {
|
||||
editor = createEditor(true)
|
||||
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
|
||||
expect(countCols(editor)).toBe(3)
|
||||
|
||||
focusFirstCell(editor)
|
||||
editor.commands.deleteColumn()
|
||||
|
||||
expect(countCellsInFirstRow(editor)).toBe(2)
|
||||
expect(countCols(editor)).toBe(2)
|
||||
})
|
||||
|
||||
it('updates <colgroup> when a column is added after', () => {
|
||||
editor = createEditor(true)
|
||||
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
|
||||
expect(countCols(editor)).toBe(3)
|
||||
|
||||
focusFirstCell(editor)
|
||||
editor.commands.addColumnAfter()
|
||||
|
||||
expect(countCellsInFirstRow(editor)).toBe(4)
|
||||
expect(countCols(editor)).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases (resizable: false)', () => {
|
||||
const getTable = (e: Editor) => e.view.dom.querySelector('table') as HTMLTableElement
|
||||
|
||||
it('updates <table> min-width style when columns change', () => {
|
||||
editor = createEditor(false)
|
||||
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false })
|
||||
|
||||
// Default cellMinWidth is 25px → 3 cols × 25 = 75px
|
||||
expect(getTable(editor).style.minWidth).toBe('75px')
|
||||
|
||||
focusFirstCell(editor)
|
||||
editor.commands.deleteColumn()
|
||||
expect(getTable(editor).style.minWidth).toBe('50px')
|
||||
|
||||
editor.commands.addColumnAfter()
|
||||
expect(getTable(editor).style.minWidth).toBe('75px')
|
||||
})
|
||||
|
||||
it('keeps <colgroup> consistent after mergeCells (no col change expected)', () => {
|
||||
editor = createEditor(false)
|
||||
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false })
|
||||
|
||||
const initialCols = countCols(editor)
|
||||
|
||||
// select two adjacent cells then merge
|
||||
editor.commands.focus()
|
||||
editor.commands.setTextSelection({ from: 3, to: 8 })
|
||||
editor.commands.mergeCells()
|
||||
|
||||
// mergeCells uses colspan; column count must not change
|
||||
expect(countCols(editor)).toBe(initialCols)
|
||||
})
|
||||
|
||||
it('updates <colgroup> in read-only mode when columns change programmatically', () => {
|
||||
editor = createEditor(false)
|
||||
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false })
|
||||
|
||||
editor.setEditable(false)
|
||||
|
||||
expect(countCols(editor)).toBe(3)
|
||||
|
||||
focusFirstCell(editor)
|
||||
editor.commands.deleteColumn()
|
||||
|
||||
expect(countCellsInFirstRow(editor)).toBe(2)
|
||||
expect(countCols(editor)).toBe(2)
|
||||
})
|
||||
|
||||
it('preserves remaining colwidths when a column is deleted', () => {
|
||||
editor = createEditor(false)
|
||||
editor.commands.setContent(
|
||||
'<table><tbody><tr><td colwidth="100">A</td><td colwidth="200">B</td><td colwidth="300">C</td></tr></tbody></table>',
|
||||
)
|
||||
|
||||
const widthsOf = () =>
|
||||
Array.from(editor!.view.dom.querySelectorAll<HTMLTableColElement>('colgroup > col')).map(
|
||||
c => c.style.width,
|
||||
)
|
||||
|
||||
expect(widthsOf()).toEqual(['100px', '200px', '300px'])
|
||||
|
||||
// place cursor in the first cell, then delete it
|
||||
editor.commands.focus()
|
||||
editor.commands.setTextSelection(3)
|
||||
editor.commands.deleteColumn()
|
||||
|
||||
// remaining widths must be the original 200 and 300, not 100/200 (which would
|
||||
// indicate widths got reassigned to the wrong columns after the deletion)
|
||||
expect(widthsOf()).toEqual(['200px', '300px'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
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 { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
const collectCellPositions = (editor: Editor): number[] => {
|
||||
const positions: number[] = []
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
|
||||
positions.push(pos)
|
||||
}
|
||||
})
|
||||
return positions
|
||||
}
|
||||
|
||||
const getCellContents = (editor: Editor): string[] => {
|
||||
const contents: string[] = []
|
||||
editor.state.doc.descendants(node => {
|
||||
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
|
||||
contents.push(node.textContent)
|
||||
}
|
||||
})
|
||||
return contents
|
||||
}
|
||||
|
||||
describe('deleteSelection with CellSelection', () => {
|
||||
let editor: Editor
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [Document, Paragraph, Text, TableKit],
|
||||
content:
|
||||
'<table><tbody><tr><td>A1</td><td>B1</td><td>C1</td></tr><tr><td>A2</td><td>B2</td><td>C2</td></tr></tbody></table>',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('clears every selected cell in a multi-cell CellSelection', () => {
|
||||
const cells = collectCellPositions(editor)
|
||||
// Row-major order: A1, B1, C1, A2, B2, C2
|
||||
// Select the 2x2 block A1..B2, leaving C1 and C2 untouched.
|
||||
editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[4] }).run()
|
||||
|
||||
const result = editor.commands.deleteSelection()
|
||||
|
||||
expect(result).toBe(true)
|
||||
|
||||
const contents = getCellContents(editor)
|
||||
expect(contents[0]).toBe('') // A1
|
||||
expect(contents[1]).toBe('') // B1
|
||||
expect(contents[2]).toBe('C1')
|
||||
expect(contents[3]).toBe('') // A2
|
||||
expect(contents[4]).toBe('') // B2
|
||||
expect(contents[5]).toBe('C2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Heading from '@tiptap/extension-heading'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
/** Regression tests for empty `<td>`/`<th>` insertion — https://github.com/ueberdosis/tiptap/issues/6237 */
|
||||
describe('extension table empty cell/header parsing', () => {
|
||||
const editorElClass = 'tiptap'
|
||||
let editor: Editor | null = null
|
||||
|
||||
const createEditorEl = () => {
|
||||
const editorEl = document.createElement('div')
|
||||
|
||||
editorEl.classList.add(editorElClass)
|
||||
document.body.appendChild(editorEl)
|
||||
return editorEl
|
||||
}
|
||||
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
|
||||
|
||||
const createTableEditor = () =>
|
||||
new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content: '<p></p>',
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('inserts a table with an empty cell without throwing, backfilling an empty paragraph', () => {
|
||||
editor = createTableEditor()
|
||||
|
||||
expect(() => {
|
||||
editor?.commands.insertContentAt(0, '<table><tr><td></td></tr></table>')
|
||||
}).not.toThrow()
|
||||
|
||||
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
|
||||
|
||||
expect(cellNode?.type).toBe('tableCell')
|
||||
expect(cellNode?.content).toEqual([{ type: 'paragraph' }])
|
||||
})
|
||||
|
||||
it('inserts a table with an empty header cell without throwing, backfilling an empty paragraph', () => {
|
||||
editor = createTableEditor()
|
||||
|
||||
expect(() => {
|
||||
editor?.commands.insertContentAt(0, '<table><tr><th></th></tr></table>')
|
||||
}).not.toThrow()
|
||||
|
||||
const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
|
||||
|
||||
expect(headerNode?.type).toBe('tableHeader')
|
||||
expect(headerNode?.content).toEqual([{ type: 'paragraph' }])
|
||||
})
|
||||
|
||||
it('inserts an empty cell via insertContent without throwing', () => {
|
||||
editor = createTableEditor()
|
||||
|
||||
expect(() => {
|
||||
editor?.commands.insertContent('<table><tr><td></td></tr></table>')
|
||||
}).not.toThrow()
|
||||
|
||||
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
|
||||
|
||||
expect(cellNode?.type).toBe('tableCell')
|
||||
expect(cellNode?.content).toEqual([{ type: 'paragraph' }])
|
||||
})
|
||||
|
||||
it('inserts an empty header cell via insertContent without throwing', () => {
|
||||
editor = createTableEditor()
|
||||
|
||||
expect(() => {
|
||||
editor?.commands.insertContent('<table><tr><th></th></tr></table>')
|
||||
}).not.toThrow()
|
||||
|
||||
const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
|
||||
|
||||
expect(headerNode?.type).toBe('tableHeader')
|
||||
expect(headerNode?.content).toEqual([{ type: 'paragraph' }])
|
||||
})
|
||||
|
||||
it('backfills a whitespace-only cell when preserveWhitespace is false', () => {
|
||||
editor = createTableEditor()
|
||||
|
||||
expect(() => {
|
||||
editor?.commands.insertContentAt(0, '<table><tr><td> </td></tr></table>', {
|
||||
parseOptions: { preserveWhitespace: false },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
|
||||
|
||||
expect(cellNode?.content).toEqual([{ type: 'paragraph' }])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['default (no parseOptions)', undefined],
|
||||
['preserveWhitespace: true', { preserveWhitespace: true as const }],
|
||||
['preserveWhitespace: "full"', { preserveWhitespace: 'full' as const }],
|
||||
])('backfills a whitespace-only cell identically to setContent — %s', (_label, parseOptions) => {
|
||||
const tableHTML = '<table><tr><td> </td></tr></table>'
|
||||
|
||||
// setContent collapses a spaces-only cell too, so no whitespace is lost.
|
||||
const setContentEditor = createTableEditor()
|
||||
setContentEditor.commands.setContent(tableHTML)
|
||||
const reference = setContentEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
|
||||
setContentEditor.destroy()
|
||||
|
||||
editor = createTableEditor()
|
||||
expect(() => {
|
||||
editor?.commands.insertContentAt(0, tableHTML, parseOptions ? { parseOptions } : undefined)
|
||||
}).not.toThrow()
|
||||
|
||||
const cellContent = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
|
||||
|
||||
expect(cellContent).toEqual(reference)
|
||||
expect(cellContent).toEqual([{ type: 'paragraph' }])
|
||||
})
|
||||
|
||||
it('produces the same empty cell as setContent does', () => {
|
||||
const tableHTML = '<table><tr><td></td></tr></table>'
|
||||
|
||||
const setContentEditor = createTableEditor()
|
||||
setContentEditor.commands.setContent(tableHTML)
|
||||
const setContentCell =
|
||||
setContentEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
|
||||
setContentEditor.destroy()
|
||||
|
||||
editor = createTableEditor()
|
||||
editor.commands.insertContentAt(0, tableHTML)
|
||||
const insertContentCell = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
|
||||
|
||||
expect(insertContentCell).toEqual(setContentCell)
|
||||
expect(insertContentCell).toEqual([{ type: 'paragraph' }])
|
||||
})
|
||||
|
||||
it('preserves a non-breaking-space cell instead of treating it as empty', () => {
|
||||
editor = createTableEditor()
|
||||
|
||||
// trim() would strip the NBSP; it must survive as text, not be backfilled.
|
||||
editor.commands.insertContentAt(0, '<table><tr><td> </td></tr></table>')
|
||||
|
||||
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
|
||||
|
||||
expect(cellNode?.type).toBe('tableCell')
|
||||
expect(cellNode?.content).toEqual([
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('backfills using the schema block when no paragraph node exists', () => {
|
||||
const el = createEditorEl()
|
||||
// A valid schema whose only block is Heading (no Paragraph).
|
||||
const headingOnlyEditor = new Editor({
|
||||
element: el,
|
||||
extensions: [Document, Text, Heading, TableCell, TableHeader, TableRow, Table],
|
||||
content: '<h1>x</h1>',
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
headingOnlyEditor.commands.insertContentAt(0, '<table><tr><td></td></tr></table>')
|
||||
}).not.toThrow()
|
||||
|
||||
const cellNode = headingOnlyEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
|
||||
|
||||
// Fill uses the schema's block (heading here), not a hard-coded paragraph.
|
||||
expect(cellNode?.content?.[0]?.type).toBe('heading')
|
||||
|
||||
headingOnlyEditor.destroy()
|
||||
el.remove()
|
||||
})
|
||||
|
||||
it('keeps non-empty cell content intact (regression)', () => {
|
||||
editor = createTableEditor()
|
||||
|
||||
editor.commands.insertContentAt(0, '<table><tr><td>hello</td></tr></table>')
|
||||
|
||||
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
|
||||
|
||||
expect(cellNode?.content).toEqual([
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps non-empty header content intact (regression)', () => {
|
||||
editor = createTableEditor()
|
||||
|
||||
editor.commands.insertContentAt(0, '<table><tr><th>hello</th></tr></table>')
|
||||
|
||||
const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
|
||||
|
||||
expect(headerNode?.content).toEqual([
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('still parses colspan/colwidth on a multi-cell table (regression)', () => {
|
||||
editor = createTableEditor()
|
||||
|
||||
editor.commands.insertContentAt(
|
||||
0,
|
||||
'<table><tr><td colwidth="200">Name</td><td colspan="2">Description</td></tr></table>',
|
||||
)
|
||||
|
||||
const row = editor.getJSON().content?.[0]?.content?.[0]
|
||||
|
||||
expect(row?.content?.[0]?.attrs?.colwidth).toEqual([200])
|
||||
expect(row?.content?.[1]?.attrs?.colspan).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
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 { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
const callClipboardTextSerializer = (editor: Editor): string => {
|
||||
const serializer = editor.view.someProp('clipboardTextSerializer') as
|
||||
| ((slice: ReturnType<typeof editor.state.selection.content>) => string)
|
||||
| undefined
|
||||
|
||||
if (!serializer) {
|
||||
throw new Error('clipboardTextSerializer prop not registered')
|
||||
}
|
||||
|
||||
return serializer(editor.state.selection.content())
|
||||
}
|
||||
|
||||
const collectCellPositions = (editor: Editor): number[] => {
|
||||
const positions: number[] = []
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
|
||||
positions.push(pos)
|
||||
}
|
||||
})
|
||||
return positions
|
||||
}
|
||||
|
||||
describe('Table plain-text clipboard serialization', () => {
|
||||
let editor: Editor
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
})
|
||||
|
||||
it('only serializes selected cells when CellSelection spans non-adjacent rows', () => {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [Document, Paragraph, Text, TableKit],
|
||||
content: `
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>A1</td><td>B1</td></tr>
|
||||
<tr><td>A2</td><td>B2</td></tr>
|
||||
<tr><td>A3</td><td>B3</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
})
|
||||
|
||||
// Document order: A1, B1, A2, B2, A3, B3
|
||||
const cells = collectCellPositions(editor)
|
||||
|
||||
// Select column 0 across all three rows: A1 -> A3.
|
||||
// ProseMirror's CellSelection turns this into a rectangular selection
|
||||
// covering A1, A2, A3 only. The bug was that the plain-text serializer
|
||||
// also pulled in B1 and B2 because they sit between A1 and A3 in doc order.
|
||||
editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[4] }).run()
|
||||
|
||||
const result = callClipboardTextSerializer(editor)
|
||||
|
||||
expect(result).toContain('A1')
|
||||
expect(result).toContain('A2')
|
||||
expect(result).toContain('A3')
|
||||
expect(result).not.toContain('B1')
|
||||
expect(result).not.toContain('B2')
|
||||
expect(result).not.toContain('B3')
|
||||
})
|
||||
|
||||
it('emits cells in document order even when CellSelection is built bottom-up', () => {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [Document, Paragraph, Text, TableKit],
|
||||
content: `
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>A1</td><td>B1</td></tr>
|
||||
<tr><td>A2</td><td>B2</td></tr>
|
||||
<tr><td>A3</td><td>B3</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
})
|
||||
|
||||
const cells = collectCellPositions(editor)
|
||||
|
||||
// Reverse selection: anchor on the bottom cell, head on the top cell.
|
||||
// Simulates a user dragging upward. ranges may not be in doc order.
|
||||
editor.chain().focus().setCellSelection({ anchorCell: cells[4], headCell: cells[0] }).run()
|
||||
|
||||
const result = callClipboardTextSerializer(editor)
|
||||
|
||||
expect(result.indexOf('A1')).toBeLessThan(result.indexOf('A2'))
|
||||
expect(result.indexOf('A2')).toBeLessThan(result.indexOf('A3'))
|
||||
})
|
||||
|
||||
it('serializes adjacent cells in a row without dropping content', () => {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [Document, Paragraph, Text, TableKit],
|
||||
content: `
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>A1</td><td>B1</td><td>C1</td></tr>
|
||||
<tr><td>A2</td><td>B2</td><td>C2</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
})
|
||||
|
||||
// Document order: A1, B1, C1, A2, B2, C2
|
||||
const cells = collectCellPositions(editor)
|
||||
|
||||
// Select A1 and B1 (same row, adjacent).
|
||||
editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[1] }).run()
|
||||
|
||||
const result = callClipboardTextSerializer(editor)
|
||||
|
||||
expect(result).toContain('A1')
|
||||
expect(result).toContain('B1')
|
||||
expect(result).not.toContain('C1')
|
||||
expect(result).not.toContain('A2')
|
||||
})
|
||||
})
|
||||
306
packages/extension-table/tests/integration/tableCommands.test.ts
Normal file
306
packages/extension-table/tests/integration/tableCommands.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
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 { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
const countCells = (editor: Editor, selector: string) =>
|
||||
editor.view.dom.querySelectorAll(selector).length
|
||||
|
||||
const firstTableBounds = (editor: Editor) => {
|
||||
let start = -1
|
||||
let end = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'table' && start === -1) {
|
||||
start = pos
|
||||
end = pos + node.nodeSize
|
||||
}
|
||||
})
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const selectFirstTwoHeaderCells = (editor: Editor) => {
|
||||
const positions: number[] = []
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'tableHeader') {
|
||||
positions.push(pos)
|
||||
}
|
||||
})
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setCellSelection({ anchorCell: positions[0], headCell: positions[1] })
|
||||
.run()
|
||||
}
|
||||
|
||||
describe('Table commands', () => {
|
||||
let editor: Editor
|
||||
|
||||
beforeEach(() => {
|
||||
const element = document.createElement('div')
|
||||
document.body.appendChild(element)
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [Document, Paragraph, Text, TableKit],
|
||||
content: '',
|
||||
})
|
||||
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('adds a table with three columns and three rows', () => {
|
||||
expect(countCells(editor, 'table')).toBe(1)
|
||||
expect(countCells(editor, 'table tr')).toBe(3)
|
||||
expect(countCells(editor, 'table th')).toBe(3)
|
||||
expect(countCells(editor, 'table td')).toBe(6)
|
||||
})
|
||||
|
||||
it('adds & deletes columns', () => {
|
||||
editor.commands.addColumnBefore()
|
||||
expect(countCells(editor, 'table th')).toBe(4)
|
||||
|
||||
editor.commands.addColumnAfter()
|
||||
expect(countCells(editor, 'table th')).toBe(5)
|
||||
|
||||
editor.commands.deleteColumn()
|
||||
editor.commands.deleteColumn()
|
||||
expect(countCells(editor, 'table th')).toBe(3)
|
||||
})
|
||||
|
||||
it('adds & deletes rows', () => {
|
||||
editor.commands.addRowBefore()
|
||||
expect(countCells(editor, 'table tr')).toBe(4)
|
||||
|
||||
editor.commands.addRowAfter()
|
||||
expect(countCells(editor, 'table tr')).toBe(5)
|
||||
|
||||
editor.commands.deleteRow()
|
||||
editor.commands.deleteRow()
|
||||
expect(countCells(editor, 'table tr')).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps the cursor inside the table when deleting the last row', () => {
|
||||
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>Text below table</p>')
|
||||
|
||||
let lastCellPos = 0
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'tableCell') {
|
||||
lastCellPos = pos
|
||||
}
|
||||
})
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(lastCellPos + 2)
|
||||
.run()
|
||||
|
||||
editor.commands.deleteRow()
|
||||
|
||||
expect(countCells(editor, 'table tr')).toBe(2)
|
||||
expect(editor.isActive('table')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the cursor inside the table when deleting the last column', () => {
|
||||
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>Text below table</p>')
|
||||
|
||||
let lastCellPos = 0
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'tableCell') {
|
||||
lastCellPos = pos
|
||||
}
|
||||
})
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(lastCellPos + 2)
|
||||
.run()
|
||||
|
||||
editor.commands.deleteColumn()
|
||||
|
||||
expect(countCells(editor, 'table th')).toBe(2)
|
||||
expect(editor.isActive('table')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the cursor in the edited table when deleting the last row with a table below', () => {
|
||||
editor.commands.insertContentAt(
|
||||
editor.state.doc.content.size,
|
||||
'<table><tbody><tr><td><p>Following table</p></td></tr></tbody></table>',
|
||||
)
|
||||
|
||||
const { end: firstTableEnd } = firstTableBounds(editor)
|
||||
let lastCellPos = 0
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'tableCell' && pos < firstTableEnd) {
|
||||
lastCellPos = pos
|
||||
}
|
||||
})
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(lastCellPos + 2)
|
||||
.run()
|
||||
|
||||
editor.commands.deleteRow()
|
||||
|
||||
const { start, end } = firstTableBounds(editor)
|
||||
const cursor = editor.state.selection.from
|
||||
expect(editor.isActive('table')).toBe(true)
|
||||
expect(cursor).toBeGreaterThan(start)
|
||||
expect(cursor).toBeLessThan(end)
|
||||
})
|
||||
|
||||
it('keeps the cursor in the edited table when deleting the last column with a table below', () => {
|
||||
editor.commands.insertContentAt(
|
||||
editor.state.doc.content.size,
|
||||
'<table><tbody><tr><td><p>Following table</p></td></tr></tbody></table>',
|
||||
)
|
||||
|
||||
const { end: firstTableEnd } = firstTableBounds(editor)
|
||||
let lastCellPos = 0
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'tableCell' && pos < firstTableEnd) {
|
||||
lastCellPos = pos
|
||||
}
|
||||
})
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(lastCellPos + 2)
|
||||
.run()
|
||||
|
||||
editor.commands.deleteColumn()
|
||||
|
||||
const { start, end } = firstTableBounds(editor)
|
||||
const cursor = editor.state.selection.from
|
||||
expect(editor.isActive('table')).toBe(true)
|
||||
expect(cursor).toBeGreaterThan(start)
|
||||
expect(cursor).toBeLessThan(end)
|
||||
})
|
||||
|
||||
it('deletes the table', () => {
|
||||
editor.commands.deleteTable()
|
||||
expect(countCells(editor, 'table')).toBe(0)
|
||||
})
|
||||
|
||||
it('merges cells', () => {
|
||||
selectFirstTwoHeaderCells(editor)
|
||||
editor.commands.mergeCells()
|
||||
expect(countCells(editor, 'table th')).toBe(2)
|
||||
})
|
||||
|
||||
it('splits cells', () => {
|
||||
selectFirstTwoHeaderCells(editor)
|
||||
editor.commands.mergeCells()
|
||||
expect(countCells(editor, 'table th')).toBe(2)
|
||||
|
||||
editor.commands.splitCell()
|
||||
expect(countCells(editor, 'table th')).toBe(3)
|
||||
})
|
||||
|
||||
it('toggles header columns', () => {
|
||||
editor.commands.toggleHeaderColumn()
|
||||
expect(countCells(editor, 'table th')).toBe(5)
|
||||
})
|
||||
|
||||
it('toggles header row', () => {
|
||||
editor.commands.toggleHeaderRow()
|
||||
expect(countCells(editor, 'table th')).toBe(0)
|
||||
})
|
||||
|
||||
it('merges and splits via mergeOrSplit', () => {
|
||||
selectFirstTwoHeaderCells(editor)
|
||||
editor.commands.mergeCells()
|
||||
expect(editor.view.dom.querySelector('th[colspan="2"]')).not.toBeNull()
|
||||
|
||||
editor.commands.mergeOrSplit()
|
||||
expect(editor.view.dom.querySelector('th[colspan="2"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('creates a 1x1 table', () => {
|
||||
editor.commands.clearContent()
|
||||
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false })
|
||||
expect(countCells(editor, 'td')).toBe(1)
|
||||
expect(countCells(editor, 'tr')).toBe(1)
|
||||
})
|
||||
|
||||
it('creates a 3x1 table', () => {
|
||||
editor.commands.clearContent()
|
||||
editor.commands.insertTable({ cols: 3, rows: 1, withHeaderRow: false })
|
||||
expect(countCells(editor, 'td')).toBe(3)
|
||||
expect(countCells(editor, 'tr')).toBe(1)
|
||||
})
|
||||
|
||||
it('creates a 1x3 table', () => {
|
||||
editor.commands.clearContent()
|
||||
editor.commands.insertTable({ cols: 1, rows: 3, withHeaderRow: false })
|
||||
expect(countCells(editor, 'td')).toBe(3)
|
||||
expect(countCells(editor, 'tr')).toBe(3)
|
||||
})
|
||||
|
||||
it('creates a 1x3 table with header row', () => {
|
||||
editor.commands.clearContent()
|
||||
editor.commands.insertTable({ cols: 1, rows: 3, withHeaderRow: true })
|
||||
expect(countCells(editor, 'th')).toBe(1)
|
||||
expect(countCells(editor, 'td')).toBe(2)
|
||||
expect(countCells(editor, 'tr')).toBe(3)
|
||||
})
|
||||
|
||||
it('creates a 3x3 table with defaults', () => {
|
||||
editor.commands.clearContent()
|
||||
editor.commands.insertTable()
|
||||
expect(countCells(editor, 'th')).toBe(3)
|
||||
expect(countCells(editor, 'td')).toBe(6)
|
||||
expect(countCells(editor, 'tr')).toBe(3)
|
||||
})
|
||||
|
||||
it('sets a minimum width on colgroups', () => {
|
||||
editor.commands.clearContent()
|
||||
editor.commands.insertTable({ cols: 3, rows: 1, withHeaderRow: false })
|
||||
const firstCol = editor.view.dom.querySelector('col')
|
||||
expect(firstCol?.getAttribute('style')).toBe('min-width: 25px;')
|
||||
})
|
||||
|
||||
it('generates correct markup for a 1x1 table', () => {
|
||||
editor.commands.clearContent()
|
||||
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false })
|
||||
expect(editor.getHTML()).toBe(
|
||||
'<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p></p></td></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
|
||||
it('generates correct markup for a 1x1 table with header', () => {
|
||||
editor.commands.clearContent()
|
||||
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: true })
|
||||
expect(editor.getHTML()).toBe(
|
||||
'<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p></p></th></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
|
||||
it('moves focus between cells with goToNextCell / goToPreviousCell', () => {
|
||||
let firstHeaderPos = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'tableHeader' && firstHeaderPos === -1) {
|
||||
firstHeaderPos = pos
|
||||
}
|
||||
})
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(firstHeaderPos + 1)
|
||||
.insertContent('Column 1')
|
||||
.run()
|
||||
|
||||
editor.commands.goToNextCell()
|
||||
editor.commands.insertContent('Column 2')
|
||||
editor.commands.goToPreviousCell()
|
||||
|
||||
const ths = editor.view.dom.querySelectorAll('table th')
|
||||
expect(ths[0].textContent).toContain('Column 1')
|
||||
expect(ths[1].textContent).toContain('Column 2')
|
||||
})
|
||||
})
|
||||
275
packages/extension-table/tests/integration/tableMarkdown.test.ts
Normal file
275
packages/extension-table/tests/integration/tableMarkdown.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Code } from '@tiptap/extension-code'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import HardBreak from '@tiptap/extension-hard-break'
|
||||
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 'vite-plus/test'
|
||||
|
||||
describe('table markdown — inline code with pipe characters', () => {
|
||||
const manager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, Code, TableKit],
|
||||
})
|
||||
|
||||
it('should parse `||` inside a code span as a single cell', () => {
|
||||
const markdown = '| Header |\n| ------ |\n| `||` |'
|
||||
const parsed = manager.parse(markdown)
|
||||
const bodyRow = parsed.content?.[0]?.content?.[1]?.content || []
|
||||
expect(bodyRow).toHaveLength(1)
|
||||
const textNode = bodyRow[0]?.content?.[0]?.content?.[0]
|
||||
expect(textNode?.text).toBe('||')
|
||||
expect(textNode?.marks?.[0]?.type).toBe('code')
|
||||
})
|
||||
|
||||
it('should parse `a || b` inside a code span as a single cell', () => {
|
||||
const markdown = '| H | H | H |\n| - | - | - |\n| `||` | or | `a || b` |'
|
||||
const parsed = manager.parse(markdown)
|
||||
const table = parsed.content?.[0]
|
||||
expect(table?.type).toBe('table')
|
||||
const bodyRow = table?.content?.[1]?.content || []
|
||||
expect(bodyRow).toHaveLength(3)
|
||||
|
||||
const cell1 = bodyRow[0]?.content?.[0]?.content?.[0]
|
||||
expect(cell1?.text).toBe('||')
|
||||
expect(cell1?.marks?.[0]?.type).toBe('code')
|
||||
|
||||
const cell2 = bodyRow[1]?.content?.[0]?.content?.[0]
|
||||
expect(cell2?.text).toBe('or')
|
||||
expect(cell2?.marks).toBeUndefined()
|
||||
|
||||
const cell3 = bodyRow[2]?.content?.[0]?.content?.[0]
|
||||
expect(cell3?.text).toBe('a || b')
|
||||
expect(cell3?.marks?.[0]?.type).toBe('code')
|
||||
})
|
||||
|
||||
it('should parse `&&` inside a code span correctly (regression guard)', () => {
|
||||
const markdown = '| H | H | H |\n| - | - | - |\n| `&&` | and | `a && b` |'
|
||||
const parsed = manager.parse(markdown)
|
||||
const bodyRow = parsed.content?.[0]?.content?.[1]?.content || []
|
||||
expect(bodyRow).toHaveLength(3)
|
||||
const cell1 = bodyRow[0]?.content?.[0]?.content?.[0]
|
||||
expect(cell1?.text).toBe('&&')
|
||||
expect(cell1?.marks?.[0]?.type).toBe('code')
|
||||
const cell3 = bodyRow[2]?.content?.[0]?.content?.[0]
|
||||
expect(cell3?.text).toBe('a && b')
|
||||
expect(cell3?.marks?.[0]?.type).toBe('code')
|
||||
})
|
||||
|
||||
it('should roundtrip table with inline code containing pipes', () => {
|
||||
const markdown = '| H |\n| - |\n| `a || b` |'
|
||||
const parsed = manager.parse(markdown)
|
||||
const serialized = manager.serialize(parsed)
|
||||
const reparsed = manager.parse(serialized)
|
||||
const cell = reparsed.content?.[0]?.content?.[1]?.content?.[0]?.content?.[0]?.content?.[0]
|
||||
expect(cell?.text).toBe('a || b')
|
||||
expect(cell?.marks?.[0]?.type).toBe('code')
|
||||
})
|
||||
|
||||
it('should not affect table cells without backtick spans', () => {
|
||||
const markdown = '| a | b |\n| - | - |\n| 1 | 2 |'
|
||||
const parsed = manager.parse(markdown)
|
||||
const bodyRow = parsed.content?.[0]?.content?.[1]?.content || []
|
||||
expect(bodyRow).toHaveLength(2)
|
||||
expect(bodyRow[0]?.content?.[0]?.content?.[0]?.text).toBe('1')
|
||||
expect(bodyRow[1]?.content?.[0]?.content?.[0]?.text).toBe('2')
|
||||
})
|
||||
|
||||
it('should not corrupt content that follows a table with inline code containing pipes', () => {
|
||||
const markdown = '| H |\n| - |\n| `a || b` |\n\nParagraph after.'
|
||||
const parsed = manager.parse(markdown)
|
||||
expect(parsed.content).toHaveLength(2)
|
||||
expect(parsed.content?.[0]?.type).toBe('table')
|
||||
expect(parsed.content?.[1]?.type).toBe('paragraph')
|
||||
expect(parsed.content?.[1]?.content?.[0]?.text).toBe('Paragraph after.')
|
||||
})
|
||||
|
||||
it('should not affect inline code with pipes outside of tables', () => {
|
||||
const markdown = 'Use `a || b` for logical or.'
|
||||
const parsed = manager.parse(markdown)
|
||||
// Must parse as a paragraph, not accidentally as a table
|
||||
const block = parsed.content?.[0]
|
||||
expect(block?.type).toBe('paragraph')
|
||||
// The code mark must be present on one of the inline nodes
|
||||
const codeNode = block?.content?.find(n => n.marks?.some(m => m.type === 'code'))
|
||||
expect(codeNode).toBeDefined()
|
||||
expect(codeNode?.marks?.[0]?.type).toBe('code')
|
||||
})
|
||||
|
||||
it('should treat a setext heading with pipe in title as a heading, not a table', () => {
|
||||
// '`a | b`\n---' is a setext h2, not a table. Our tokenizer must not
|
||||
// activate on it and must not contaminate the inline queue via
|
||||
// helper.blockTokens (which would turn '|' into '\|' in the output).
|
||||
const markdown = '`a | b`\n---'
|
||||
const parsed = manager.parse(markdown)
|
||||
const block = parsed.content?.[0]
|
||||
expect(block?.type).toBe('heading')
|
||||
// The pipe must be preserved verbatim inside the code span.
|
||||
const codeNode = block?.content?.find((n: any) => n.marks?.some((m: any) => m.type === 'code'))
|
||||
expect(codeNode?.text).toBe('a | b')
|
||||
})
|
||||
|
||||
it('should parse `||` inside a code span in a pipeless table as a single cell', () => {
|
||||
const markdown = 'Expression | Meaning\n---------- | -------\n`||` | or'
|
||||
const parsed = manager.parse(markdown)
|
||||
const table = parsed.content?.[0]
|
||||
expect(table?.type).toBe('table')
|
||||
const bodyRow = table?.content?.[1]?.content || []
|
||||
expect(bodyRow).toHaveLength(2)
|
||||
const cell1 = bodyRow[0]?.content?.[0]?.content?.[0]
|
||||
expect(cell1?.text).toBe('||')
|
||||
expect(cell1?.marks?.[0]?.type).toBe('code')
|
||||
const cell2 = bodyRow[1]?.content?.[0]?.content?.[0]
|
||||
expect(cell2?.text).toBe('or')
|
||||
})
|
||||
|
||||
it('should not corrupt content that follows a pipeless table with inline code containing pipes', () => {
|
||||
const markdown = 'H1 | H2\n-- | --\n`a || b` | x\n\nParagraph after.'
|
||||
const parsed = manager.parse(markdown)
|
||||
expect(parsed.content).toHaveLength(2)
|
||||
expect(parsed.content?.[0]?.type).toBe('table')
|
||||
expect(parsed.content?.[1]?.type).toBe('paragraph')
|
||||
expect(parsed.content?.[1]?.content?.[0]?.text).toBe('Paragraph after.')
|
||||
})
|
||||
|
||||
it('should include a no-pipe body row as a table row, matching marked native behavior', () => {
|
||||
// marked treats any non-blank line before the blank line as a table body row,
|
||||
// even if it contains no pipe. Our tokenizer must not cut it off into a paragraph.
|
||||
const markdown = 'A | B\n--- | ---\n`a || b` | c\njust some text\n\nAfter'
|
||||
const parsed = manager.parse(markdown)
|
||||
const table = parsed.content?.[0]
|
||||
expect(table?.type).toBe('table')
|
||||
// 2 body rows: "`a || b` | c" and "just some text"
|
||||
expect(table?.content).toHaveLength(3) // header row + 2 body rows
|
||||
expect(parsed.content?.[1]?.type).toBe('paragraph')
|
||||
expect(parsed.content?.[1]?.content?.[0]?.text).toBe('After')
|
||||
})
|
||||
})
|
||||
|
||||
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*\|/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('table markdown line breaks', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, HardBreak, TableKit],
|
||||
})
|
||||
|
||||
const cell = (content: Record<string, unknown>[]) => ({
|
||||
type: 'tableCell',
|
||||
content,
|
||||
})
|
||||
|
||||
const tableDoc = (cells: ReturnType<typeof cell>[]) => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'table',
|
||||
content: [{ type: 'tableRow', content: cells }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
it('keeps hard breaks in cells as <br>', () => {
|
||||
const doc = tableDoc([
|
||||
cell([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'foo' },
|
||||
{ type: 'hardBreak' },
|
||||
{ type: 'text', text: 'bar' },
|
||||
],
|
||||
},
|
||||
]),
|
||||
])
|
||||
|
||||
const serialized = markdownManager.serialize(doc)
|
||||
|
||||
expect(serialized).toContain('foo<br>bar')
|
||||
})
|
||||
|
||||
it('keeps consecutive hard breaks as separate <br> tags', () => {
|
||||
const doc = tableDoc([
|
||||
cell([
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'foo' },
|
||||
{ type: 'hardBreak' },
|
||||
{ type: 'hardBreak' },
|
||||
{ type: 'text', text: 'bar' },
|
||||
],
|
||||
},
|
||||
]),
|
||||
])
|
||||
|
||||
const serialized = markdownManager.serialize(doc)
|
||||
|
||||
expect(serialized).toContain('foo<br><br>bar')
|
||||
})
|
||||
|
||||
it('round trips <br> inside cells', () => {
|
||||
const markdown = `| desc |
|
||||
| --- |
|
||||
| foo<br>bar |`
|
||||
|
||||
const parsed = markdownManager.parse(markdown)
|
||||
const parsedText = JSON.stringify(parsed)
|
||||
|
||||
expect(parsedText).toContain('"hardBreak"')
|
||||
|
||||
const serialized = markdownManager.serialize(parsed)
|
||||
|
||||
expect(serialized).toContain('foo<br>bar')
|
||||
})
|
||||
|
||||
it('joins multiple paragraphs in a cell with <br>', () => {
|
||||
const doc = tableDoc([
|
||||
cell([
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] },
|
||||
]),
|
||||
])
|
||||
|
||||
const serialized = markdownManager.serialize(doc)
|
||||
|
||||
expect(serialized).toContain('one<br>two')
|
||||
expect(serialized).not.toContain('\u001F')
|
||||
})
|
||||
|
||||
it('leaves cells without breaks alone', () => {
|
||||
const doc = tableDoc([
|
||||
cell([{ type: 'paragraph', content: [{ type: 'text', text: 'plain' }] }]),
|
||||
])
|
||||
|
||||
const serialized = markdownManager.serialize(doc)
|
||||
|
||||
expect(serialized).toContain('| plain |')
|
||||
expect(serialized).not.toContain('<br>')
|
||||
})
|
||||
})
|
||||
25
packages/extension-table/vite.config.ts
Normal file
25
packages/extension-table/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { tsupCompatibleExtensions } from '../../pack.config.mjs'
|
||||
|
||||
const entries = [
|
||||
'src/table/index.ts',
|
||||
'src/cell/index.ts',
|
||||
'src/header/index.ts',
|
||||
'src/kit/index.ts',
|
||||
'src/row/index.ts',
|
||||
'src/index.ts',
|
||||
]
|
||||
|
||||
export default defineConfig({
|
||||
pack: entries.map(entry => ({
|
||||
entry: [entry],
|
||||
tsconfig: '../../tsconfig.build.json',
|
||||
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
|
||||
dts: { sourcemap: true },
|
||||
sourcemap: true,
|
||||
target: 'es2019',
|
||||
format: ['esm', 'cjs'],
|
||||
deps: { neverBundle: [/^[^./]/] },
|
||||
outExtensions: tsupCompatibleExtensions,
|
||||
})),
|
||||
})
|
||||
Reference in New Issue
Block a user