diff --git a/docs/src/demos/Nodes/Table/index.spec.js b/docs/src/demos/Nodes/Table/index.spec.js new file mode 100644 index 00000000..e70768b8 --- /dev/null +++ b/docs/src/demos/Nodes/Table/index.spec.js @@ -0,0 +1,7 @@ +context('/api/nodes/table', () => { + before(() => { + cy.visit('/api/nodes/table') + }) + + // TODO: Write tests +}) diff --git a/docs/src/demos/Nodes/Table/index.vue b/docs/src/demos/Nodes/Table/index.vue new file mode 100644 index 00000000..8d82ce82 --- /dev/null +++ b/docs/src/demos/Nodes/Table/index.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/docs/src/docPages/api/nodes/table-header.md b/docs/src/docPages/api/nodes/table-header.md new file mode 100644 index 00000000..ea9e355a --- /dev/null +++ b/docs/src/docPages/api/nodes/table-header.md @@ -0,0 +1,8 @@ +# TableHeader + +:::pro Fund the development 💖 +We need your support to maintain, update, support and develop tiptap 2. If you’re waiting for this extension, [become a sponsor and fund open source](/sponsor). +::: + +TODO + diff --git a/docs/src/docPages/api/nodes/table.md b/docs/src/docPages/api/nodes/table.md index 9ffa2192..88bd0ac9 100644 --- a/docs/src/docPages/api/nodes/table.md +++ b/docs/src/docPages/api/nodes/table.md @@ -5,3 +5,10 @@ We need your support to maintain, update, support and develop tiptap 2. If you ::: TODO + +⚠️ Preview + +Tasks +- backspace: when all cells are selected, delete table + + diff --git a/docs/src/links.yaml b/docs/src/links.yaml index 846e9f46..f7cfac02 100644 --- a/docs/src/links.yaml +++ b/docs/src/links.yaml @@ -139,6 +139,9 @@ - title: TableCell link: /api/nodes/table-cell type: draft + - title: TableHeader + link: /api/nodes/table-header + type: draft - title: TaskList link: /api/nodes/task-list - title: TaskItem diff --git a/packages/core/package.json b/packages/core/package.json index 4f2a148a..6c929e0b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,6 +30,7 @@ "@types/prosemirror-model": "^1.11.2", "@types/prosemirror-schema-list": "^1.0.2", "@types/prosemirror-state": "^1.2.6", + "@types/prosemirror-tables": "^0.9.1", "@types/prosemirror-transform": "^1.1.2", "@types/prosemirror-view": "^1.17.1", "prosemirror-commands": "^1.1.3", diff --git a/packages/core/src/Node.ts b/packages/core/src/Node.ts index f5ba9cc2..6f04f242 100644 --- a/packages/core/src/Node.ts +++ b/packages/core/src/Node.ts @@ -68,6 +68,11 @@ export interface NodeConfig extends Overwrite NodeSpec['isolating']), + /** + * Table Role + */ + tableRole?: NodeSpec['tableRole'] | ((this: { options: Options }) => NodeSpec['tableRole']), + /** * Parse HTML */ diff --git a/packages/core/src/commands/focus.ts b/packages/core/src/commands/focus.ts index b4fa862b..8b6d13d4 100644 --- a/packages/core/src/commands/focus.ts +++ b/packages/core/src/commands/focus.ts @@ -1,6 +1,7 @@ import { EditorState, TextSelection } from 'prosemirror-state' import { Command, FocusPosition } from '../types' import minMax from '../utilities/minMax' +import isTextSelection from '../helpers/isTextSelection' function resolveSelection(state: EditorState, position: FocusPosition = null) { if (!position) { @@ -42,6 +43,12 @@ export const focus = (position: FocusPosition = null): Command => ({ return true } + // we don’t try to resolve a NodeSelection or CellSelection + if (dispatch && position === null && !isTextSelection(editor.state.selection)) { + view.focus() + return true + } + const { from, to } = resolveSelection(editor.state, position) || editor.state.selection const { doc } = tr const resolvedFrom = minMax(from, 0, doc.content.size) diff --git a/packages/core/src/extensions/focusEvents.ts b/packages/core/src/extensions/focusEvents.ts index 8dd43a4e..68e4cf15 100644 --- a/packages/core/src/extensions/focusEvents.ts +++ b/packages/core/src/extensions/focusEvents.ts @@ -24,7 +24,7 @@ export const FocusEvents = Extension.create({ view.dispatch(transaction) - return true + return false }, blur: (view, event) => { editor.isFocused = false @@ -35,7 +35,7 @@ export const FocusEvents = Extension.create({ view.dispatch(transaction) - return true + return false }, }, }, diff --git a/packages/core/src/helpers/findParentNodeClosestToPos.ts b/packages/core/src/helpers/findParentNodeClosestToPos.ts new file mode 100644 index 00000000..d9bff48e --- /dev/null +++ b/packages/core/src/helpers/findParentNodeClosestToPos.ts @@ -0,0 +1,23 @@ +import { ResolvedPos, Node as ProsemirrorNode } from 'prosemirror-model' + +export type Predicate = (node: ProsemirrorNode) => boolean + +export default function findParentNodeClosestToPos($pos: ResolvedPos, predicate: Predicate): ({ + pos: number, + start: number, + depth: number, + node: ProsemirrorNode, +} | undefined) { + for (let i = $pos.depth; i > 0; i -= 1) { + const node = $pos.node(i) + + if (predicate(node)) { + return { + pos: i > 0 ? $pos.before(i) : 0, + start: $pos.start(i), + depth: i, + node, + } + } + } +} diff --git a/packages/core/src/helpers/getSchema.ts b/packages/core/src/helpers/getSchema.ts index cd279ab1..2f65659a 100644 --- a/packages/core/src/helpers/getSchema.ts +++ b/packages/core/src/helpers/getSchema.ts @@ -36,6 +36,7 @@ export default function getSchema(extensions: Extensions): Schema { code: callOrReturn(extension.config.code, context), defining: callOrReturn(extension.config.defining, context), isolating: callOrReturn(extension.config.isolating, context), + tableRole: callOrReturn(extension.config.tableRole, context), attrs: Object.fromEntries(extensionAttributes.map(extensionAttribute => { return [extensionAttribute.name, { default: extensionAttribute?.attribute?.default }] })), diff --git a/packages/core/src/helpers/isCellSelection.ts b/packages/core/src/helpers/isCellSelection.ts new file mode 100644 index 00000000..0288c0b7 --- /dev/null +++ b/packages/core/src/helpers/isCellSelection.ts @@ -0,0 +1,6 @@ +import { CellSelection } from 'prosemirror-tables' +import isObject from '../utilities/isObject' + +export default function isCellSelection(value: unknown): value is CellSelection { + return isObject(value) && value instanceof CellSelection +} diff --git a/packages/core/src/helpers/isNodeSelection.ts b/packages/core/src/helpers/isNodeSelection.ts new file mode 100644 index 00000000..c1dc13d3 --- /dev/null +++ b/packages/core/src/helpers/isNodeSelection.ts @@ -0,0 +1,6 @@ +import { NodeSelection } from 'prosemirror-state' +import isObject from '../utilities/isObject' + +export default function isNodeSelection(value: unknown): value is NodeSelection { + return isObject(value) && value instanceof NodeSelection +} diff --git a/packages/core/src/helpers/isTextSelection.ts b/packages/core/src/helpers/isTextSelection.ts new file mode 100644 index 00000000..b49a3d31 --- /dev/null +++ b/packages/core/src/helpers/isTextSelection.ts @@ -0,0 +1,6 @@ +import { TextSelection } from 'prosemirror-state' +import isObject from '../utilities/isObject' + +export default function isTextSelection(value: unknown): value is TextSelection { + return isObject(value) && value instanceof TextSelection +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0cdef07b..e3967ae8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,5 +16,9 @@ export { default as mergeAttributes } from './utilities/mergeAttributes' export { default as isActive } from './helpers/isActive' export { default as isMarkActive } from './helpers/isMarkActive' export { default as isNodeActive } from './helpers/isNodeActive' +export { default as isNodeSelection } from './helpers/isNodeSelection' +export { default as isTextSelection } from './helpers/isTextSelection' +export { default as isCellSelection } from './helpers/isCellSelection' +export { default as findParentNodeClosestToPos } from './helpers/findParentNodeClosestToPos' export interface AllExtensions {} diff --git a/packages/extension-table-cell/README.md b/packages/extension-table-cell/README.md new file mode 100644 index 00000000..8924b07e --- /dev/null +++ b/packages/extension-table-cell/README.md @@ -0,0 +1,14 @@ +# @tiptap/extension-table-cell +[![Version](https://img.shields.io/npm/v/@tiptap/extension-table-cell.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-table-cell) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-table-cell.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-table-cell.svg)](https://www.npmjs.com/package/@tiptap/extension-table-cell) +[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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*. + +## Offical 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-next/blob/main/LICENSE.md). diff --git a/packages/extension-table-cell/package.json b/packages/extension-table-cell/package.json new file mode 100644 index 00000000..e5cbc249 --- /dev/null +++ b/packages/extension-table-cell/package.json @@ -0,0 +1,27 @@ +{ + "name": "@tiptap/extension-table-cell", + "description": "table cell extension for tiptap", + "version": "2.0.0-alpha.5", + "homepage": "https://tiptap.dev", + "keywords": [ + "tiptap", + "tiptap extension" + ], + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "main": "dist/tiptap-extension-table-cell.cjs.js", + "umd": "dist/tiptap-extension-table-cell.umd.js", + "module": "dist/tiptap-extension-table-cell.esm.js", + "unpkg": "dist/tiptap-extension-table-cell.bundle.umd.min.js", + "types": "dist/packages/extension-table-cell/src/index.d.ts", + "files": [ + "src", + "dist" + ], + "peerDependencies": { + "@tiptap/core": "^2.0.0-alpha.6" + } +} diff --git a/packages/extension-table-cell/src/index.ts b/packages/extension-table-cell/src/index.ts new file mode 100644 index 00000000..d3091a48 --- /dev/null +++ b/packages/extension-table-cell/src/index.ts @@ -0,0 +1,5 @@ +import { TableCell } from './table-cell' + +export * from './table-cell' + +export default TableCell diff --git a/packages/extension-table-cell/src/table-cell.ts b/packages/extension-table-cell/src/table-cell.ts new file mode 100644 index 00000000..4faee886 --- /dev/null +++ b/packages/extension-table-cell/src/table-cell.ts @@ -0,0 +1,51 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export interface TableCellOptions { + HTMLAttributes: { + [key: string]: any + }, +} +export const TableCell = Node.create({ + name: 'tableCell', + + defaultOptions: { + HTMLAttributes: {}, + }, + + content: 'block+', + + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + }, + } + }, + + tableRole: 'cell', + + isolating: true, + + parseHTML() { + return [ + { tag: 'td' }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + +}) + +declare module '@tiptap/core' { + interface AllExtensions { + TableCell: typeof TableCell, + } +} diff --git a/packages/extension-table-header/README.md b/packages/extension-table-header/README.md new file mode 100644 index 00000000..4dba7844 --- /dev/null +++ b/packages/extension-table-header/README.md @@ -0,0 +1,14 @@ +# @tiptap/extension-table-header +[![Version](https://img.shields.io/npm/v/@tiptap/extension-table-header.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-table-header) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-table-header.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-table-header.svg)](https://www.npmjs.com/package/@tiptap/extension-table-header) +[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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*. + +## Offical 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-next/blob/main/LICENSE.md). diff --git a/packages/extension-table-header/package.json b/packages/extension-table-header/package.json new file mode 100644 index 00000000..b08cc214 --- /dev/null +++ b/packages/extension-table-header/package.json @@ -0,0 +1,27 @@ +{ + "name": "@tiptap/extension-table-header", + "description": "table cell extension for tiptap", + "version": "2.0.0-alpha.5", + "homepage": "https://tiptap.dev", + "keywords": [ + "tiptap", + "tiptap extension" + ], + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "main": "dist/tiptap-extension-table-header.cjs.js", + "umd": "dist/tiptap-extension-table-header.umd.js", + "module": "dist/tiptap-extension-table-header.esm.js", + "unpkg": "dist/tiptap-extension-table-header.bundle.umd.min.js", + "types": "dist/packages/extension-table-header/src/index.d.ts", + "files": [ + "src", + "dist" + ], + "peerDependencies": { + "@tiptap/core": "^2.0.0-alpha.6" + } +} diff --git a/packages/extension-table-header/src/index.ts b/packages/extension-table-header/src/index.ts new file mode 100644 index 00000000..bfa5d100 --- /dev/null +++ b/packages/extension-table-header/src/index.ts @@ -0,0 +1,5 @@ +import { TableHeader } from './table-header' + +export * from './table-header' + +export default TableHeader diff --git a/packages/extension-table-header/src/table-header.ts b/packages/extension-table-header/src/table-header.ts new file mode 100644 index 00000000..f7ebf212 --- /dev/null +++ b/packages/extension-table-header/src/table-header.ts @@ -0,0 +1,51 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export interface TableHeaderOptions { + HTMLAttributes: { + [key: string]: any + }, +} +export const TableHeader = Node.create({ + name: 'tableHeader', + + defaultOptions: { + HTMLAttributes: {}, + }, + + content: 'block+', + + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + }, + } + }, + + tableRole: 'header_cell', + + isolating: true, + + parseHTML() { + return [ + { tag: 'th' }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + +}) + +declare module '@tiptap/core' { + interface AllExtensions { + TableHeader: typeof TableHeader, + } +} diff --git a/packages/extension-table-row/README.md b/packages/extension-table-row/README.md new file mode 100644 index 00000000..a1249cd8 --- /dev/null +++ b/packages/extension-table-row/README.md @@ -0,0 +1,14 @@ +# @tiptap/extension-table-row +[![Version](https://img.shields.io/npm/v/@tiptap/extension-table-row.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-table-row) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-table-row.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-table-row.svg)](https://www.npmjs.com/package/@tiptap/extension-table-row) +[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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*. + +## Offical 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-next/blob/main/LICENSE.md). diff --git a/packages/extension-table-row/package.json b/packages/extension-table-row/package.json new file mode 100644 index 00000000..bc604250 --- /dev/null +++ b/packages/extension-table-row/package.json @@ -0,0 +1,27 @@ +{ + "name": "@tiptap/extension-table-row", + "description": "table row extension for tiptap", + "version": "2.0.0-alpha.5", + "homepage": "https://tiptap.dev", + "keywords": [ + "tiptap", + "tiptap extension" + ], + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "main": "dist/tiptap-extension-table-row.cjs.js", + "umd": "dist/tiptap-extension-table-row.umd.js", + "module": "dist/tiptap-extension-table-row.esm.js", + "unpkg": "dist/tiptap-extension-table-row.bundle.umd.min.js", + "types": "dist/packages/extension-table-row/src/index.d.ts", + "files": [ + "src", + "dist" + ], + "peerDependencies": { + "@tiptap/core": "^2.0.0-alpha.6" + } +} diff --git a/packages/extension-table-row/src/index.ts b/packages/extension-table-row/src/index.ts new file mode 100644 index 00000000..bdba9f60 --- /dev/null +++ b/packages/extension-table-row/src/index.ts @@ -0,0 +1,5 @@ +import { TableRow } from './table-row' + +export * from './table-row' + +export default TableRow diff --git a/packages/extension-table-row/src/table-row.ts b/packages/extension-table-row/src/table-row.ts new file mode 100644 index 00000000..bc85b946 --- /dev/null +++ b/packages/extension-table-row/src/table-row.ts @@ -0,0 +1,35 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export interface TableRowOptions { + HTMLAttributes: { + [key: string]: any + }, +} + +export const TableRow = Node.create({ + name: 'tableRow', + + defaultOptions: { + HTMLAttributes: {}, + }, + + content: '(tableCell | tableHeader)*', + + tableRole: 'row', + + parseHTML() { + return [ + { tag: 'tr' }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, +}) + +declare module '@tiptap/core' { + interface AllExtensions { + TableRow: typeof TableRow, + } +} diff --git a/packages/extension-table/README.md b/packages/extension-table/README.md new file mode 100644 index 00000000..797c9c1b --- /dev/null +++ b/packages/extension-table/README.md @@ -0,0 +1,14 @@ +# @tiptap/extension-table +[![Version](https://img.shields.io/npm/v/@tiptap/extension-table.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-table) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-table.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-table.svg)](https://www.npmjs.com/package/@tiptap/extension-table) +[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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*. + +## Offical 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-next/blob/main/LICENSE.md). diff --git a/packages/extension-table/package.json b/packages/extension-table/package.json new file mode 100644 index 00000000..ba2fc39b --- /dev/null +++ b/packages/extension-table/package.json @@ -0,0 +1,31 @@ +{ + "name": "@tiptap/extension-table", + "description": "table extension for tiptap", + "version": "2.0.0-alpha.5", + "homepage": "https://tiptap.dev", + "keywords": [ + "tiptap", + "tiptap extension" + ], + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "main": "dist/tiptap-extension-table.cjs.js", + "umd": "dist/tiptap-extension-table.umd.js", + "module": "dist/tiptap-extension-table.esm.js", + "unpkg": "dist/tiptap-extension-table.bundle.umd.min.js", + "types": "dist/packages/extension-table/src/index.d.ts", + "files": [ + "src", + "dist" + ], + "peerDependencies": { + "@tiptap/core": "^2.0.0-alpha.6" + }, + "dependencies": { + "prosemirror-tables": "^1.1.1", + "prosemirror-view": "^1.16.3" + } +} diff --git a/packages/extension-table/src/TableView.ts b/packages/extension-table/src/TableView.ts new file mode 100644 index 00000000..5e27f08c --- /dev/null +++ b/packages/extension-table/src/TableView.ts @@ -0,0 +1,89 @@ +// @ts-nocheck +import { NodeView } from 'prosemirror-view' +import { Node as ProseMirrorNode } from 'prosemirror-model' + +export function updateColumns(node: ProseMirrorNode, colgroup: Element, table: Element, cellMinWidth: number, overrideCol?: number, overrideValue?: any) { + let totalWidth = 0 + let fixedWidth = true + let nextDOM = colgroup.firstChild + const row = node.firstChild + + 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] + const cssWidth = hasWidth ? `${hasWidth}px` : '' + totalWidth += hasWidth || cellMinWidth + + if (!hasWidth) { + fixedWidth = false + } + + if (!nextDOM) { + colgroup.appendChild(document.createElement('col')).style.width = cssWidth + } else { + if (nextDOM.style.width !== cssWidth) { + nextDOM.style.width = cssWidth + } + + nextDOM = nextDOM.nextSibling + } + } + } + + while (nextDOM) { + const after = nextDOM.nextSibling + nextDOM.parentNode.removeChild(nextDOM) + nextDOM = after + } + + if (fixedWidth) { + table.style.width = `${totalWidth}px` + table.style.minWidth = '' + } else { + table.style.width = '' + table.style.minWidth = `${totalWidth}px` + } +} + +export class TableView implements NodeView { + + node: ProseMirrorNode + + cellMinWidth: number + + dom: Element + + table: Element + + colgroup: Element + + contentDOM: Element + + constructor(node: ProseMirrorNode, cellMinWidth: number) { + this.node = node + this.cellMinWidth = cellMinWidth + this.dom = document.createElement('div') + this.dom.className = 'tableWrapper' + this.table = this.dom.appendChild(document.createElement('table')) + 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: ProseMirrorNode) { + if (node.type !== this.node.type) { + return false + } + + this.node = node + updateColumns(node, this.colgroup, this.table, this.cellMinWidth) + + return true + } + + ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) { + return mutation.type === 'attributes' && (mutation.target === this.table || this.colgroup.contains(mutation.target)) + } +} diff --git a/packages/extension-table/src/index.ts b/packages/extension-table/src/index.ts new file mode 100644 index 00000000..bac8d297 --- /dev/null +++ b/packages/extension-table/src/index.ts @@ -0,0 +1,5 @@ +import { Table } from './table' + +export * from './table' + +export default Table diff --git a/packages/extension-table/src/table.ts b/packages/extension-table/src/table.ts new file mode 100644 index 00000000..2b96b101 --- /dev/null +++ b/packages/extension-table/src/table.ts @@ -0,0 +1,230 @@ +import { + Command, + Node, + mergeAttributes, + isCellSelection, + findParentNodeClosestToPos, +} from '@tiptap/core' +import { + tableEditing, + columnResizing, + goToNextCell, + addColumnBefore, + addColumnAfter, + deleteColumn, + addRowBefore, + addRowAfter, + deleteRow, + deleteTable, + mergeCells, + splitCell, + toggleHeaderColumn, + toggleHeaderRow, + toggleHeaderCell, + setCellAttr, + fixTables, +} from 'prosemirror-tables' +import { NodeView } from 'prosemirror-view' +import { TextSelection } from 'prosemirror-state' +import { createTable } from './utilities/createTable' +import { TableView } from './TableView' + +export interface TableOptions { + HTMLAttributes: { + [key: string]: any + }, + resizable: boolean, + handleWidth: number, + cellMinWidth: number, + View: NodeView, + lastColumnResizable: boolean, + allowTableNodeSelection: boolean, +} + +export const Table = Node.create({ + name: 'table', + + defaultOptions: { + HTMLAttributes: {}, + resizable: false, + handleWidth: 5, + cellMinWidth: 25, + View: TableView, + lastColumnResizable: true, + allowTableNodeSelection: false, + }, + + content: 'tableRow+', + + tableRole: 'table', + + isolating: true, + + group: 'block', + + parseHTML() { + return [ + { tag: 'table' }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['table', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ['tbody', 0]] + }, + + addCommands() { + return { + insertTable: ({ rows = 3, cols = 3, withHeaderRow = true }): Command => ({ tr, dispatch, editor }) => { + const offset = tr.selection.anchor + 1 + const node = createTable(editor.schema, rows, cols, withHeaderRow) + + if (dispatch) { + tr.replaceSelectionWith(node) + .scrollIntoView() + .setSelection(TextSelection.near(tr.doc.resolve(offset))) + } + + return true + }, + addColumnBefore: (): Command => ({ state, dispatch }) => { + return addColumnBefore(state, dispatch) + }, + addColumnAfter: (): Command => ({ state, dispatch }) => { + return addColumnAfter(state, dispatch) + }, + deleteColumn: (): Command => ({ state, dispatch }) => { + return deleteColumn(state, dispatch) + }, + addRowBefore: (): Command => ({ state, dispatch }) => { + return addRowBefore(state, dispatch) + }, + addRowAfter: (): Command => ({ state, dispatch }) => { + return addRowAfter(state, dispatch) + }, + deleteRow: (): Command => ({ state, dispatch }) => { + return deleteRow(state, dispatch) + }, + deleteTable: (): Command => ({ state, dispatch }) => { + return deleteTable(state, dispatch) + }, + mergeCells: (): Command => ({ state, dispatch }) => { + return mergeCells(state, dispatch) + }, + splitCell: (): Command => ({ state, dispatch }) => { + return splitCell(state, dispatch) + }, + toggleHeaderColumn: (): Command => ({ state, dispatch }) => { + return toggleHeaderColumn(state, dispatch) + }, + toggleHeaderRow: (): Command => ({ state, dispatch }) => { + return toggleHeaderRow(state, dispatch) + }, + toggleHeaderCell: (): Command => ({ state, dispatch }) => { + return toggleHeaderCell(state, dispatch) + }, + mergeOrSplit: (): Command => ({ state, dispatch }) => { + if (mergeCells(state, dispatch)) { + return true + } + + return splitCell(state, dispatch) + }, + setCellAttributes: ({ name, value }: { name: string, value: any }): Command => ({ state, dispatch }) => { + return setCellAttr(name, value)(state, dispatch) + }, + goToNextCell: (): Command => ({ state, dispatch }) => { + return goToNextCell(1)(state, dispatch) + }, + goToPreviousCell: (): Command => ({ state, dispatch }) => { + return goToNextCell(-1)(state, dispatch) + }, + fixTables: (): Command => ({ state, dispatch }) => { + if (dispatch) { + fixTables(state) + } + + return true + }, + } + }, + + addKeyboardShortcuts() { + const deleteTableWhenAllCellsSelected = () => { + const { selection } = this.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 + } + + this.editor.commands.deleteTable() + + return true + } + + 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() { + return [ + ...(this.options.resizable ? [columnResizing({ + handleWidth: this.options.handleWidth, + cellMinWidth: this.options.cellMinWidth, + View: this.options.View, + // TODO: PR for @types/prosemirror-tables + // @ts-ignore (incorrect type) + lastColumnResizable: this.options.lastColumnResizable, + })] : []), + tableEditing({ + allowTableNodeSelection: this.options.allowTableNodeSelection, + }), + ] + }, +}) + +declare module '@tiptap/core' { + interface AllExtensions { + Table: typeof Table, + } +} diff --git a/packages/extension-table/src/utilities/createCell.ts b/packages/extension-table/src/utilities/createCell.ts new file mode 100644 index 00000000..65d52df3 --- /dev/null +++ b/packages/extension-table/src/utilities/createCell.ts @@ -0,0 +1,13 @@ +import { + NodeType, Fragment, + Node as ProsemirrorNode, + Schema, +} from 'prosemirror-model' + +export function createCell(cellType: NodeType, cellContent?: Fragment | ProsemirrorNode | Array>): ProsemirrorNode | null | undefined { + if (cellContent) { + return cellType.createChecked(null, cellContent) + } + + return cellType.createAndFill() +} diff --git a/packages/extension-table/src/utilities/createTable.ts b/packages/extension-table/src/utilities/createTable.ts new file mode 100644 index 00000000..812fc491 --- /dev/null +++ b/packages/extension-table/src/utilities/createTable.ts @@ -0,0 +1,33 @@ +import { Schema, Fragment, Node as ProsemirrorNode } from 'prosemirror-model' +import { createCell } from './createCell' +import { getTableNodeTypes } from './getTableNodeTypes' + +export function createTable(schema: Schema, rowsCount: number, colsCount: number, withHeaderRow: boolean, cellContent?: Fragment | ProsemirrorNode | Array>): ProsemirrorNode { + const types = getTableNodeTypes(schema) + const headerCells = [] + const cells = [] + + 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 = [] + + 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) +} diff --git a/packages/extension-table/src/utilities/getTableNodeTypes.ts b/packages/extension-table/src/utilities/getTableNodeTypes.ts new file mode 100644 index 00000000..38010a96 --- /dev/null +++ b/packages/extension-table/src/utilities/getTableNodeTypes.ts @@ -0,0 +1,21 @@ +import { Schema, NodeType } from 'prosemirror-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 +} diff --git a/yarn.lock b/yarn.lock index 589b6f0f..c20fb438 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2486,6 +2486,13 @@ "@types/prosemirror-transform" "*" "@types/prosemirror-view" "*" +"@types/prosemirror-tables@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@types/prosemirror-tables/-/prosemirror-tables-0.9.1.tgz#d2203330f0fa1161c04152bf02c39e152082d408" + integrity sha512-zoY1qcAC6kG4UjnaQQXuoyYQdDJMQmY9uzRKdyUppP8rWRR5/kXBHOd84CD9ZvrYUBo3uDmS20qQnc3knr2j9A== + dependencies: + prosemirror-tables "*" + "@types/prosemirror-transform@*": version "1.1.1" resolved "https://registry.yarnpkg.com/@types/prosemirror-transform/-/prosemirror-transform-1.1.1.tgz#5a0de16e8e0123b4c3d9559235e19f39cee85e5c" @@ -11842,7 +11849,7 @@ prosemirror-state@^1.3.4: prosemirror-model "^1.0.0" prosemirror-transform "^1.0.0" -prosemirror-tables@^1.1.1: +prosemirror-tables@*, prosemirror-tables@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.1.1.tgz#ad66300cc49500455cf1243bb129c9e7d883321e" integrity sha512-LmCz4jrlqQZRsYRDzCRYf/pQ5CUcSOyqZlAj5kv67ZWBH1SVLP2U9WJEvQfimWgeRlIz0y0PQVqO1arRm1+woA==