├── .npmignore ├── tsconfig.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── tsdown.config.ts ├── eslint.config.js ├── vitest.config.ts ├── test ├── typescript │ ├── tsconfig.json │ └── prosemirror-tables.ts ├── transpose.test.ts ├── column-resizing.test.ts ├── input.test.ts ├── convert-table-node-to-array-of-rows.test.ts ├── build.ts ├── fixtable.test.ts ├── tablemap.test.ts ├── move-row-in-array-of-rows.test.ts ├── cellselection.test.ts ├── convert-array-of-rows-to-table-node.test.ts ├── copypaste.test.ts └── commands.test.ts ├── .github ├── workflows │ ├── coverage.yaml │ ├── release.yaml │ └── ci.yml └── actions │ └── setup │ └── action.yml ├── src ├── utils │ ├── transpose.ts │ ├── move-row-in-array-of-rows.ts │ ├── get-cells.ts │ ├── move-row.ts │ ├── move-column.ts │ ├── query.ts │ ├── convert.ts │ └── selection-range.ts ├── README.md ├── tableview.ts ├── index.ts ├── util.ts ├── fixtables.ts ├── schema.ts ├── input.ts ├── tablemap.ts ├── copypaste.ts ├── columnresizing.ts └── cellselection.ts ├── LICENSE ├── style └── tables.css ├── demo ├── index.html └── demo.ts ├── package.json ├── CHANGELOG.md └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /test -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@ocavue/tsconfig/dom/app.json" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .DS_Store 4 | .vscode/ 5 | coverage/ 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | README.md 2 | 3 | node_modules/ 4 | dist/ 5 | pnpm-lock.yaml 6 | CHANGELOG.md 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "bracketSpacing": true 5 | } 6 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown'; 2 | 3 | export default defineConfig({ 4 | target: 'es2018', 5 | entry: ['src/index.ts'], 6 | format: ['esm', 'cjs'], 7 | dts: true, 8 | fixedExtension: false, 9 | clean: true, 10 | }); 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { defineESLintConfig } from '@ocavue/eslint-config'; 2 | 3 | export default defineESLintConfig({}, [ 4 | { 5 | rules: { 6 | 'unicorn/prefer-math-trunc': 'off', 7 | 'unicorn/no-for-loop': 'off', 8 | }, 9 | }, 10 | ]); 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'happy-dom', 6 | coverage: { 7 | enabled: true, 8 | reporter: ['text', 'json-summary', 'json'], 9 | reportOnFailure: true, 10 | }, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /test/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "lib": ["es6", "dom"], 5 | "noImplicitAny": true, 6 | "noImplicitThis": true, 7 | "strictNullChecks": true, 8 | "strictFunctionTypes": true, 9 | "baseUrl": "../", 10 | "typeRoots": ["../"], 11 | "types": [], 12 | "noEmit": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "files": ["index.ts", "prosemirror-tables-tests.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | workflow_run: 5 | workflows: ['ci'] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | report: 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | pull-requests: write 15 | 16 | steps: 17 | - uses: actions/checkout@v6 18 | - uses: actions/download-artifact@v4 19 | with: 20 | github-token: ${{ secrets.GITHUB_TOKEN }} 21 | run-id: ${{ github.event.workflow_run.id }} 22 | - name: Report Coverage 23 | uses: davelosert/vitest-coverage-report-action@v2 24 | with: 25 | file-coverage-mode: all 26 | -------------------------------------------------------------------------------- /test/transpose.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { transpose } from '../src/utils/transpose'; 4 | 5 | describe('transpose', () => { 6 | const arr = [ 7 | ['a1', 'a2', 'a3'], 8 | ['b1', 'b2', 'b3'], 9 | ['c1', 'c2', 'c3'], 10 | ['d1', 'd2', 'd3'], 11 | ]; 12 | 13 | const expected = [ 14 | ['a1', 'b1', 'c1', 'd1'], 15 | ['a2', 'b2', 'c2', 'd2'], 16 | ['a3', 'b3', 'c3', 'd3'], 17 | ]; 18 | 19 | it('should invert columns to rows', () => { 20 | expect(transpose(arr)).toEqual(expected); 21 | }); 22 | 23 | it('should guarantee the reflection to be true ', () => { 24 | expect(transpose(expected)).toEqual(arr); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | description: Shared setup for all actions 3 | 4 | inputs: 5 | node-version: 6 | description: The version of node.js 7 | required: false 8 | default: '24' 9 | 10 | runs: 11 | using: composite 12 | steps: 13 | - name: Install pnpm 14 | uses: pnpm/action-setup@v4 15 | 16 | - name: Setup node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ inputs.node-version }} 20 | cache: pnpm 21 | registry-url: 'https://registry.npmjs.org' 22 | 23 | - name: Install dependencies 24 | run: pnpm install 25 | shell: bash 26 | 27 | - name: Build 28 | run: pnpm build 29 | shell: bash 30 | -------------------------------------------------------------------------------- /test/column-resizing.test.ts: -------------------------------------------------------------------------------- 1 | import ist from 'ist'; 2 | import { EditorState } from 'prosemirror-state'; 3 | import { DecorationSet } from 'prosemirror-view'; 4 | import { describe, it } from 'vitest'; 5 | 6 | import { handleDecorations } from '../src/columnresizing'; 7 | 8 | import { table, doc, tr, cEmpty } from './build'; 9 | 10 | describe('handleDecorations', () => { 11 | it('returns an empty DecorationSet if cell is null or undefined', () => { 12 | const state = EditorState.create({ 13 | doc: doc(table(tr(/* 2*/ cEmpty, /* 6*/ cEmpty, /*10*/ cEmpty))), 14 | }); 15 | // @ts-expect-error: null is not a valid number 16 | ist(handleDecorations(state, null), DecorationSet.empty); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/utils/transpose.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Transposes a 2D array by flipping columns to rows. 3 | * 4 | * Transposition is a familiar algebra concept where the matrix is flipped 5 | * along its diagonal. For more details, see: 6 | * https://en.wikipedia.org/wiki/Transpose 7 | * 8 | * @example 9 | * ```javascript 10 | * const arr = [ 11 | * ['a1', 'a2', 'a3'], 12 | * ['b1', 'b2', 'b3'], 13 | * ['c1', 'c2', 'c3'], 14 | * ['d1', 'd2', 'd3'], 15 | * ]; 16 | * 17 | * const result = transpose(arr); 18 | * result === [ 19 | * ['a1', 'b1', 'c1', 'd1'], 20 | * ['a2', 'b2', 'c2', 'd2'], 21 | * ['a3', 'b3', 'c3', 'd3'], 22 | * ] 23 | * ``` 24 | */ 25 | export function transpose(array: T[][]): T[][] { 26 | return array[0].map((_, i) => { 27 | return array.map((column) => column[i]); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | version: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: googleapis/release-please-action@v4 13 | id: release-please 14 | with: 15 | release-type: node 16 | outputs: 17 | release_created: ${{ steps.release-please.outputs.release_created }} 18 | 19 | publish: 20 | runs-on: ubuntu-latest 21 | needs: [version] 22 | permissions: 23 | contents: write 24 | id-token: write 25 | if: ${{ needs.version.outputs.release_created }} 26 | steps: 27 | - uses: actions/checkout@v6 28 | 29 | - uses: ./.github/actions/setup 30 | 31 | - name: Build 32 | run: pnpm run build 33 | 34 | - name: Publish to NPM 35 | run: pnpm publish 36 | -------------------------------------------------------------------------------- /test/typescript/prosemirror-tables.ts: -------------------------------------------------------------------------------- 1 | import { Node as ProsemirrorNode } from 'prosemirror-model'; 2 | import { EditorState } from 'prosemirror-state'; 3 | 4 | import type { TableRect } from '../../src'; 5 | import { CellSelection, tableEditing, TableMap, toggleHeader } from '../../src'; 6 | 7 | export const tableEditing1 = tableEditing(); 8 | export const tableWithNodeSelection = tableEditing({ 9 | allowTableNodeSelection: true, 10 | }); 11 | 12 | const map = new TableMap(0, 0, [], null); 13 | const table = new ProsemirrorNode(); 14 | 15 | toggleHeader('column'); 16 | toggleHeader('row'); 17 | toggleHeader('row', { useDeprecatedLogic: false }); 18 | toggleHeader('row', { useDeprecatedLogic: true }); 19 | 20 | export const tableRect: TableRect = { 21 | left: 10, 22 | top: 20, 23 | right: 30, 24 | bottom: 40, 25 | tableStart: 20, 26 | map, 27 | table, 28 | }; 29 | 30 | EditorState.create({ 31 | doc: table, 32 | selection: CellSelection.create(table, 0), 33 | }); 34 | -------------------------------------------------------------------------------- /src/utils/move-row-in-array-of-rows.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Move a row in an array of rows. 3 | * 4 | * @internal 5 | */ 6 | export function moveRowInArrayOfRows( 7 | rows: T[], 8 | indexesOrigin: number[], 9 | indexesTarget: number[], 10 | directionOverride: -1 | 1 | 0, 11 | ): T[] { 12 | const direction = indexesOrigin[0] > indexesTarget[0] ? -1 : 1; 13 | 14 | const rowsExtracted = rows.splice(indexesOrigin[0], indexesOrigin.length); 15 | const positionOffset = rowsExtracted.length % 2 === 0 ? 1 : 0; 16 | let target: number; 17 | 18 | if (directionOverride === -1 && direction === 1) { 19 | target = indexesTarget[0] - 1; 20 | } else if (directionOverride === 1 && direction === -1) { 21 | target = indexesTarget[indexesTarget.length - 1] - positionOffset + 1; 22 | } else { 23 | target = 24 | direction === -1 25 | ? indexesTarget[0] 26 | : indexesTarget[indexesTarget.length - 1] - positionOffset; 27 | } 28 | 29 | rows.splice(target, 0, ...rowsExtracted); 30 | return rows; 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015-2016 by Marijn Haverbeke and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /style/tables.css: -------------------------------------------------------------------------------- 1 | .ProseMirror .tableWrapper { 2 | overflow-x: auto; 3 | } 4 | .ProseMirror table { 5 | border-collapse: collapse; 6 | table-layout: fixed; 7 | width: 100%; 8 | overflow: hidden; 9 | } 10 | .ProseMirror td, 11 | .ProseMirror th { 12 | vertical-align: top; 13 | box-sizing: border-box; 14 | position: relative; 15 | } 16 | 17 | .ProseMirror td:not([data-colwidth]):not(.column-resize-dragging), 18 | .ProseMirror th:not([data-colwidth]):not(.column-resize-dragging) { 19 | /* if there's no explicit width set and the column is not being resized, set a default width */ 20 | min-width: var(--default-cell-min-width); 21 | } 22 | 23 | .ProseMirror .column-resize-handle { 24 | position: absolute; 25 | right: -2px; 26 | top: 0; 27 | bottom: 0; 28 | width: 4px; 29 | z-index: 20; 30 | background-color: #adf; 31 | pointer-events: none; 32 | } 33 | .ProseMirror.resize-cursor { 34 | cursor: ew-resize; 35 | cursor: col-resize; 36 | } 37 | /* Give selected cells a blue overlay */ 38 | .ProseMirror .selectedCell:after { 39 | z-index: 2; 40 | position: absolute; 41 | content: ''; 42 | left: 0; 43 | right: 0; 44 | top: 0; 45 | bottom: 0; 46 | background: rgba(200, 200, 255, 0.4); 47 | pointer-events: none; 48 | } 49 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Table demo 4 | 5 | 40 | 41 |

Table demo

42 | 43 | 62 | 63 |
64 | 65 | 66 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # ProseMirror table module 2 | 3 | This module defines a schema extension to support tables with 4 | rowspan/colspan support, a custom selection class for cell selections 5 | in such a table, a plugin to manage such selections and enforce 6 | invariants on such tables, and a number of commands to work with 7 | tables. 8 | 9 | The `demo` directory contains a `demo.ts` and `index.html`, which 10 | can be built with `pnpm run build_demo` to show a simple demo of how the 11 | module can be used. 12 | 13 | ## [Live Demo](https://prosemirror-tables.netlify.app/) 14 | 15 | ## Documentation 16 | 17 | The module's main file exports everything you need to work with it. 18 | The first thing you'll probably want to do is create a table-enabled 19 | schema. That's what `tableNodes` is for: 20 | 21 | @tableNodes 22 | 23 | @tableEditing 24 | 25 | @CellSelection 26 | 27 | ### Commands 28 | 29 | The following commands can be used to make table-editing functionality 30 | available to users. 31 | 32 | @addColumnBefore 33 | 34 | @addColumnAfter 35 | 36 | @deleteColumn 37 | 38 | @addRowBefore 39 | 40 | @addRowAfter 41 | 42 | @deleteRow 43 | 44 | @mergeCells 45 | 46 | @splitCell 47 | 48 | @splitCellWithType 49 | 50 | @setCellAttr 51 | 52 | @toggleHeaderRow 53 | 54 | @toggleHeaderColumn 55 | 56 | @toggleHeaderCell 57 | 58 | @toggleHeader 59 | 60 | @goToNextCell 61 | 62 | @deleteTable 63 | 64 | ### Utilities 65 | 66 | @fixTables 67 | 68 | @TableMap 69 | -------------------------------------------------------------------------------- /src/utils/get-cells.ts: -------------------------------------------------------------------------------- 1 | import type { Selection } from 'prosemirror-state'; 2 | 3 | import { TableMap } from '../tablemap'; 4 | 5 | import type { FindNodeResult } from './query'; 6 | import { findTable } from './query'; 7 | 8 | /** 9 | * Returns an array of cells in a column at the specified column index. 10 | * 11 | * @internal 12 | */ 13 | export function getCellsInColumn( 14 | columnIndex: number, 15 | selection: Selection, 16 | ): FindNodeResult[] | undefined { 17 | const table = findTable(selection.$from); 18 | if (!table) { 19 | return; 20 | } 21 | 22 | const map = TableMap.get(table.node); 23 | 24 | if (columnIndex < 0 || columnIndex > map.width - 1) { 25 | return; 26 | } 27 | 28 | const cells = map.cellsInRect({ 29 | left: columnIndex, 30 | right: columnIndex + 1, 31 | top: 0, 32 | bottom: map.height, 33 | }); 34 | 35 | return cells.map((nodePos) => { 36 | const node = table.node.nodeAt(nodePos)!; 37 | const pos = nodePos + table.start; 38 | return { pos, start: pos + 1, node, depth: table.depth + 2 }; 39 | }); 40 | } 41 | 42 | /** 43 | * Returns an array of cells in a row at the specified row index. 44 | * 45 | * @internal 46 | */ 47 | export function getCellsInRow( 48 | rowIndex: number, 49 | selection: Selection, 50 | ): FindNodeResult[] | undefined { 51 | const table = findTable(selection.$from); 52 | if (!table) { 53 | return; 54 | } 55 | 56 | const map = TableMap.get(table.node); 57 | 58 | if (rowIndex < 0 || rowIndex > map.height - 1) { 59 | return; 60 | } 61 | 62 | const cells = map.cellsInRect({ 63 | left: 0, 64 | right: map.width, 65 | top: rowIndex, 66 | bottom: rowIndex + 1, 67 | }); 68 | 69 | return cells.map((nodePos) => { 70 | const node = table.node.nodeAt(nodePos)!; 71 | const pos = nodePos + table.start; 72 | return { pos, start: pos + 1, node, depth: table.depth + 2 }; 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | 14 | - uses: ./.github/actions/setup 15 | 16 | - name: Typecheck 17 | run: pnpm run typecheck 18 | 19 | - name: Lint 20 | run: pnpm run lint 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | 25 | permissions: 26 | contents: read 27 | pull-requests: write 28 | 29 | steps: 30 | - uses: actions/checkout@v6 31 | 32 | - uses: ./.github/actions/setup 33 | 34 | - name: Test 35 | run: pnpm run test 36 | 37 | - name: Upload Coverage 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: coverage 41 | path: coverage 42 | 43 | demo: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v6 47 | 48 | - uses: ./.github/actions/setup 49 | 50 | - name: Build 51 | run: pnpm run build_demo 52 | 53 | - name: Deploy to Netlify 54 | uses: nwtgck/actions-netlify@v1.2 55 | with: 56 | publish-dir: './demo/dist' 57 | production-branch: master 58 | github-token: ${{ secrets.GITHUB_TOKEN }} 59 | deploy-message: 'Deploy from GitHub Actions' 60 | enable-pull-request-comment: true 61 | enable-commit-comment: false 62 | overwrites-pull-request-comment: true 63 | alias: ${{ github.head_ref }} 64 | env: 65 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 66 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 67 | timeout-minutes: 5 68 | 69 | publish-snapshot: 70 | runs-on: ubuntu-latest 71 | 72 | steps: 73 | - uses: actions/checkout@v6 74 | 75 | - uses: ./.github/actions/setup 76 | 77 | - name: Build packages 78 | run: pnpm run build 79 | 80 | - name: Publish snapshot packages 81 | if: ${{ github.event_name == 'pull_request' }} 82 | run: ./node_modules/.bin/pkg-pr-new publish --pnpm 83 | -------------------------------------------------------------------------------- /test/input.test.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from 'prosemirror-state'; 2 | import { EditorState } from 'prosemirror-state'; 3 | import { EditorView } from 'prosemirror-view'; 4 | import { describe, expect, it } from 'vitest'; 5 | 6 | import { arrow } from '../src/input'; 7 | 8 | import type { TaggedNode } from './build'; 9 | import { c11, cCursor, cCursorBefore, selectionFor, table, tr } from './build'; 10 | 11 | function test( 12 | doc: TaggedNode, 13 | command: Command, 14 | result: TaggedNode | null | undefined, 15 | ) { 16 | let state = EditorState.create({ doc, selection: selectionFor(doc) }); 17 | const view = new EditorView(document.createElement('div'), { state }); 18 | const ran = command(state, (tr) => (state = state.apply(tr)), view); 19 | if (result == null) { 20 | expect(ran).toBe(false); 21 | } else { 22 | const expected = { 23 | doc: result.toJSON(), 24 | selection: selectionFor(result).toJSON(), 25 | }; 26 | const actual = state.toJSON(); 27 | expect(actual).toEqual(expected); 28 | } 29 | } 30 | 31 | describe('arrow', () => { 32 | it('can move cursor to the right cell', () => 33 | test( 34 | table(tr(c11, c11, c11), tr(c11, cCursor, c11), tr(c11, c11, c11)), 35 | arrow('horiz', 1), 36 | table(tr(c11, c11, c11), tr(c11, c11, cCursorBefore), tr(c11, c11, c11)), 37 | )); 38 | 39 | it('can move cursor to the left cell', () => 40 | test( 41 | table(tr(c11, c11, c11), tr(c11, c11, cCursorBefore), tr(c11, c11, c11)), 42 | arrow('horiz', -1), 43 | table(tr(c11, c11, c11), tr(c11, cCursor, c11), tr(c11, c11, c11)), 44 | )); 45 | 46 | it('can move cursor to the bottom cell', () => 47 | test( 48 | table(tr(c11, c11, c11), tr(c11, cCursorBefore, c11), tr(c11, c11, c11)), 49 | arrow('vert', 1), 50 | table(tr(c11, c11, c11), tr(c11, c11, c11), tr(c11, cCursorBefore, c11)), 51 | )); 52 | 53 | it('can move cursor to the top cell', () => 54 | test( 55 | table(tr(c11, c11, c11), tr(c11, c11, c11), tr(c11, cCursorBefore, c11)), 56 | arrow('vert', -1), 57 | table(tr(c11, c11, c11), tr(c11, cCursorBefore, c11), tr(c11, c11, c11)), 58 | )); 59 | }); 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-tables", 3 | "type": "module", 4 | "version": "1.8.5", 5 | "packageManager": "pnpm@10.26.2", 6 | "description": "ProseMirror's rowspan/colspan tables component", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/ProseMirror/prosemirror-tables.git" 11 | }, 12 | "main": "dist/index.cjs", 13 | "module": "dist/index.js", 14 | "types": "dist/index.d.ts", 15 | "style": "style/tables.css", 16 | "exports": { 17 | ".": { 18 | "import": "./dist/index.js", 19 | "require": "./dist/index.cjs" 20 | }, 21 | "./style/tables.css": "./style/tables.css" 22 | }, 23 | "files": [ 24 | "style", 25 | "dist" 26 | ], 27 | "scripts": { 28 | "dev": "vite demo", 29 | "build_demo": "vite build demo", 30 | "typecheck": "tsc --noEmit", 31 | "test": "vitest", 32 | "build": "tsdown", 33 | "watch": "tsdown --watch", 34 | "build_readme": "builddocs --name tables --format markdown --main src/README.md src/index.ts > README.md", 35 | "format": "prettier --write .", 36 | "lint": "eslint . && prettier --check . && pnpm run typecheck", 37 | "fix": "eslint --fix . && prettier --write ." 38 | }, 39 | "dependencies": { 40 | "prosemirror-keymap": "^1.2.3", 41 | "prosemirror-model": "^1.25.4", 42 | "prosemirror-state": "^1.4.4", 43 | "prosemirror-transform": "^1.10.5", 44 | "prosemirror-view": "^1.41.4" 45 | }, 46 | "devDependencies": { 47 | "@ocavue/eslint-config": "^3.8.0", 48 | "@ocavue/tsconfig": "^0.6.2", 49 | "@vitest/coverage-v8": "^4.0.16", 50 | "builddocs": "^1.0.8", 51 | "eslint": "^9.39.2", 52 | "happy-dom": "^20.0.11", 53 | "ist": "^1.1.7", 54 | "pkg-pr-new": "^0.0.62", 55 | "prettier": "^3.7.4", 56 | "prosemirror-commands": "^1.7.1", 57 | "prosemirror-example-setup": "^1.2.3", 58 | "prosemirror-gapcursor": "^1.4.0", 59 | "prosemirror-menu": "^1.2.5", 60 | "prosemirror-schema-basic": "^1.2.4", 61 | "prosemirror-test-builder": "^1.1.1", 62 | "tsdown": "^0.18.2", 63 | "typescript": "^5.9.3", 64 | "vite": "^7.3.0", 65 | "vitest": "^4.0.16" 66 | }, 67 | "maintainers": [ 68 | { 69 | "name": "Eduard Shvedai", 70 | "email": "eshvedai@atlassian.com" 71 | }, 72 | { 73 | "name": "Huanhuan Huang", 74 | "email": "hhuang@atlassian.com" 75 | }, 76 | { 77 | "name": "Ocavue", 78 | "email": "ocavue@gmail.com" 79 | } 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/move-row.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'prosemirror-model'; 2 | import type { Transaction } from 'prosemirror-state'; 3 | 4 | import { CellSelection } from '../cellselection'; 5 | import { TableMap } from '../tablemap'; 6 | 7 | import { 8 | convertArrayOfRowsToTableNode, 9 | convertTableNodeToArrayOfRows, 10 | } from './convert'; 11 | import { moveRowInArrayOfRows } from './move-row-in-array-of-rows'; 12 | import { findTable } from './query'; 13 | import { getSelectionRangeInRow } from './selection-range'; 14 | 15 | /** 16 | * Parameters for moving a row in a table. 17 | * 18 | * @internal 19 | */ 20 | export interface MoveRowParams { 21 | tr: Transaction; 22 | originIndex: number; 23 | targetIndex: number; 24 | select: boolean; 25 | pos: number; 26 | } 27 | 28 | /** 29 | * Move a row from index `origin` to index `target`. 30 | * 31 | * @internal 32 | */ 33 | export function moveRow(moveRowParams: MoveRowParams): boolean { 34 | const { tr, originIndex, targetIndex, select, pos } = moveRowParams; 35 | const $pos = tr.doc.resolve(pos); 36 | const table = findTable($pos); 37 | if (!table) return false; 38 | 39 | const indexesOriginRow = getSelectionRangeInRow(tr, originIndex)?.indexes; 40 | const indexesTargetRow = getSelectionRangeInRow(tr, targetIndex)?.indexes; 41 | 42 | if (!indexesOriginRow || !indexesTargetRow) return false; 43 | 44 | if (indexesOriginRow.includes(targetIndex)) return false; 45 | 46 | const newTable = moveTableRow( 47 | table.node, 48 | indexesOriginRow, 49 | indexesTargetRow, 50 | 0, 51 | ); 52 | 53 | tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTable); 54 | 55 | if (!select) return true; 56 | 57 | const map = TableMap.get(newTable); 58 | const start = table.start; 59 | const index = targetIndex; 60 | const lastCell = map.positionAt(index, map.width - 1, newTable); 61 | const $lastCell = tr.doc.resolve(start + lastCell); 62 | 63 | const firstCell = map.positionAt(index, 0, newTable); 64 | const $firstCell = tr.doc.resolve(start + firstCell); 65 | 66 | tr.setSelection(CellSelection.rowSelection($lastCell, $firstCell)); 67 | return true; 68 | } 69 | 70 | function moveTableRow( 71 | table: Node, 72 | indexesOrigin: number[], 73 | indexesTarget: number[], 74 | direction: -1 | 1 | 0, 75 | ) { 76 | let rows = convertTableNodeToArrayOfRows(table); 77 | 78 | rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction); 79 | 80 | return convertArrayOfRowsToTableNode(table, rows); 81 | } 82 | -------------------------------------------------------------------------------- /test/convert-table-node-to-array-of-rows.test.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'prosemirror-model'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | import { convertTableNodeToArrayOfRows } from '../src/utils/convert'; 5 | 6 | import { p, table, td, tr } from './build'; 7 | 8 | describe('convertTableNodeToArrayOfRows', () => { 9 | const convert = (tableNode: Node): (string | null)[][] => { 10 | const rows = convertTableNodeToArrayOfRows(tableNode); 11 | return rows.map((row) => row.map((cell) => cell?.textContent ?? null)); 12 | }; 13 | 14 | it('should convert a simple table to array of rows', () => { 15 | const tableNode = table(tr(td('A1'), td('B1')), tr(td('A2'), td('B2'))); 16 | 17 | expect(convert(tableNode)).toEqual([ 18 | ['A1', 'B1'], 19 | ['A2', 'B2'], 20 | ]); 21 | }); 22 | 23 | it('should handle empty cells', () => { 24 | const tableNode = table(tr(td('A1'), td()), tr(td(), td('B2'))); 25 | 26 | expect(convert(tableNode)).toEqual([ 27 | ['A1', ''], 28 | ['', 'B2'], 29 | ]); 30 | }); 31 | 32 | it('should handle tables with equal row lengths', () => { 33 | const tableNode = table( 34 | tr(td('A1'), td('B1'), td('C1')), 35 | tr(td('A2'), td('B2'), td('C2')), 36 | ); 37 | 38 | expect(convert(tableNode)).toEqual([ 39 | ['A1', 'B1', 'C1'], 40 | ['A2', 'B2', 'C2'], 41 | ]); 42 | }); 43 | 44 | it('should handle single row table', () => { 45 | const tableNode = table(tr(td('Single'), td('Row'))); 46 | 47 | expect(convert(tableNode)).toEqual([['Single', 'Row']]); 48 | }); 49 | 50 | it('should handle single column table', () => { 51 | const tableNode = table(tr(td('A1')), tr(td('A2')), tr(td('A3'))); 52 | 53 | expect(convert(tableNode)).toEqual([['A1'], ['A2'], ['A3']]); 54 | }); 55 | 56 | it('should handle table with merged cells', () => { 57 | // ┌──────┬──────┬─────────────┐ 58 | // │ A1 │ B1 │ C1 │ 59 | // ├──────┼──────┴──────┬──────┤ 60 | // │ A2 │ B2 │ │ 61 | // ├──────┼─────────────┤ D1 │ 62 | // │ A3 │ B3 │ C3 │ │ 63 | // └──────┴──────┴──────┴──────┘ 64 | const tableNode = table( 65 | tr(td('A1'), td('B1'), td({ colspan: 2 }, p('C1'))), 66 | tr(td('A2'), td({ colspan: 2 }, p('B2')), td({ rowspan: 2 }, p('D1'))), 67 | tr(td('A3'), td('B3'), td('C3')), 68 | ); 69 | 70 | expect(convert(tableNode)).toEqual([ 71 | ['A1', 'B1', 'C1', null], 72 | ['A2', 'B2', null, 'D1'], 73 | ['A3', 'B3', 'C3', null], 74 | ]); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/build.ts: -------------------------------------------------------------------------------- 1 | import type { Node, ResolvedPos } from 'prosemirror-model'; 2 | import { Schema } from 'prosemirror-model'; 3 | import { schema as baseSchema } from 'prosemirror-schema-basic'; 4 | import { NodeSelection, TextSelection } from 'prosemirror-state'; 5 | import { builders, eq } from 'prosemirror-test-builder'; 6 | 7 | import { CellSelection, cellAround, tableNodes } from '../src'; 8 | 9 | export type TaggedNode = Node & { tag: Record }; 10 | 11 | const schema = new Schema({ 12 | nodes: baseSchema.spec.nodes.append( 13 | tableNodes({ 14 | tableGroup: 'block', 15 | cellContent: 'block+', 16 | cellAttributes: { 17 | test: { default: 'default' }, 18 | }, 19 | }), 20 | ), 21 | marks: baseSchema.spec.marks, 22 | }); 23 | 24 | function resolveCell( 25 | doc: Node, 26 | tag: number | null | undefined, 27 | ): ResolvedPos | null { 28 | if (tag == null) return null; 29 | return cellAround(doc.resolve(tag)); 30 | } 31 | 32 | const nodeBuilders = builders(schema, { 33 | p: { nodeType: 'paragraph' }, 34 | tr: { nodeType: 'table_row' }, 35 | td: { nodeType: 'table_cell' }, 36 | th: { nodeType: 'table_header' }, 37 | }); 38 | 39 | export const { doc, table, tr, p, td, th } = nodeBuilders; 40 | 41 | export function c(colspan: number, rowspan: number, text = 'x') { 42 | return td({ colspan, rowspan }, p(text)); 43 | } 44 | 45 | export const c11 = c(1, 1); 46 | export const cEmpty = td(p()); 47 | export const cCursor = td(p('x')); 48 | export const cCursorBefore = td(p('x')); 49 | export const cAnchor = td(p('x')); 50 | export const cHead = td(p('x')); 51 | 52 | export function h(colspan: number, rowspan: number, text = 'x') { 53 | return th({ colspan, rowspan }, p(text)); 54 | } 55 | export const h11 = h(1, 1); 56 | export const hEmpty = th(p()); 57 | export const hCursor = th(p('x')); 58 | 59 | export function selectionFor(doc: TaggedNode) { 60 | const cursor = doc.tag.cursor; 61 | if (cursor != null) { 62 | return new TextSelection(doc.resolve(cursor)); 63 | } 64 | 65 | const $anchor = resolveCell(doc, doc.tag.anchor); 66 | if ($anchor) { 67 | return new CellSelection( 68 | $anchor, 69 | resolveCell(doc, doc.tag.head) || undefined, 70 | ); 71 | } 72 | 73 | const node = doc.tag.node; 74 | if (node != null) { 75 | return new NodeSelection(doc.resolve(node)); 76 | } 77 | 78 | throw new Error( 79 | 'No selection found in document. Please tag the document with , or and ', 80 | ); 81 | } 82 | 83 | export { eq }; 84 | -------------------------------------------------------------------------------- /src/utils/move-column.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'prosemirror-model'; 2 | import type { Transaction } from 'prosemirror-state'; 3 | 4 | import { CellSelection } from '../cellselection'; 5 | import { TableMap } from '../tablemap'; 6 | 7 | import { 8 | convertArrayOfRowsToTableNode, 9 | convertTableNodeToArrayOfRows, 10 | } from './convert'; 11 | import { moveRowInArrayOfRows } from './move-row-in-array-of-rows'; 12 | import { findTable } from './query'; 13 | import { getSelectionRangeInColumn } from './selection-range'; 14 | import { transpose } from './transpose'; 15 | 16 | /** 17 | * Parameters for moving a column in a table. 18 | * 19 | * @internal 20 | */ 21 | export interface MoveColumnParams { 22 | tr: Transaction; 23 | originIndex: number; 24 | targetIndex: number; 25 | select: boolean; 26 | pos: number; 27 | } 28 | 29 | /** 30 | * Move a column from index `origin` to index `target`. 31 | * 32 | * @internal 33 | */ 34 | export function moveColumn(moveColParams: MoveColumnParams): boolean { 35 | const { tr, originIndex, targetIndex, select, pos } = moveColParams; 36 | const $pos = tr.doc.resolve(pos); 37 | const table = findTable($pos); 38 | if (!table) return false; 39 | 40 | const indexesOriginColumn = getSelectionRangeInColumn( 41 | tr, 42 | originIndex, 43 | )?.indexes; 44 | const indexesTargetColumn = getSelectionRangeInColumn( 45 | tr, 46 | targetIndex, 47 | )?.indexes; 48 | 49 | if (!indexesOriginColumn || !indexesTargetColumn) return false; 50 | 51 | if (indexesOriginColumn.includes(targetIndex)) return false; 52 | 53 | const newTable = moveTableColumn( 54 | table.node, 55 | indexesOriginColumn, 56 | indexesTargetColumn, 57 | 0, 58 | ); 59 | 60 | tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTable); 61 | 62 | if (!select) return true; 63 | 64 | const map = TableMap.get(newTable); 65 | const start = table.start; 66 | const index = targetIndex; 67 | const lastCell = map.positionAt(map.height - 1, index, newTable); 68 | const $lastCell = tr.doc.resolve(start + lastCell); 69 | 70 | const firstCell = map.positionAt(0, index, newTable); 71 | const $firstCell = tr.doc.resolve(start + firstCell); 72 | 73 | tr.setSelection(CellSelection.colSelection($lastCell, $firstCell)); 74 | return true; 75 | } 76 | 77 | function moveTableColumn( 78 | table: Node, 79 | indexesOrigin: number[], 80 | indexesTarget: number[], 81 | direction: -1 | 1 | 0, 82 | ) { 83 | let rows = transpose(convertTableNodeToArrayOfRows(table)); 84 | 85 | rows = moveRowInArrayOfRows(rows, indexesOrigin, indexesTarget, direction); 86 | rows = transpose(rows); 87 | 88 | return convertArrayOfRowsToTableNode(table, rows); 89 | } 90 | -------------------------------------------------------------------------------- /src/utils/query.ts: -------------------------------------------------------------------------------- 1 | import type { Node, ResolvedPos } from 'prosemirror-model'; 2 | import type { Selection } from 'prosemirror-state'; 3 | 4 | import { CellSelection } from '../cellselection'; 5 | import { cellAround, cellNear, inSameTable } from '../util'; 6 | 7 | /** 8 | * Checks if the given object is a `CellSelection` instance. 9 | * 10 | * @internal 11 | */ 12 | function isCellSelection(value: unknown): value is CellSelection { 13 | return value instanceof CellSelection; 14 | } 15 | 16 | /** 17 | * Find the closest table node for a given position. 18 | * 19 | * @public 20 | */ 21 | export function findTable($pos: ResolvedPos): FindNodeResult | null { 22 | return findParentNode((node) => node.type.spec.tableRole === 'table', $pos); 23 | } 24 | 25 | /** 26 | * Try to find the anchor and head cell in the same table by using the given 27 | * anchor and head as hit points, or fallback to the selection's anchor and 28 | * head. 29 | * 30 | * @public 31 | */ 32 | export function findCellRange( 33 | selection: Selection, 34 | anchorHit?: number, 35 | headHit?: number, 36 | ): [ResolvedPos, ResolvedPos] | null { 37 | if (anchorHit == null && headHit == null && isCellSelection(selection)) { 38 | return [selection.$anchorCell, selection.$headCell]; 39 | } 40 | 41 | const anchor: number = anchorHit ?? headHit ?? selection.anchor; 42 | const head: number = headHit ?? anchorHit ?? selection.head; 43 | 44 | const doc = selection.$head.doc; 45 | 46 | const $anchorCell = findCellPos(doc, anchor); 47 | const $headCell = findCellPos(doc, head); 48 | 49 | if ($anchorCell && $headCell && inSameTable($anchorCell, $headCell)) { 50 | return [$anchorCell, $headCell]; 51 | } 52 | return null; 53 | } 54 | 55 | /** 56 | * Try to find a resolved pos of a cell by using the given pos as a hit point. 57 | * 58 | * @public 59 | */ 60 | export function findCellPos(doc: Node, pos: number): ResolvedPos | undefined { 61 | const $pos = doc.resolve(pos); 62 | return cellAround($pos) || cellNear($pos); 63 | } 64 | 65 | /** 66 | * Result of finding a parent node. 67 | * 68 | * @public 69 | */ 70 | export interface FindNodeResult { 71 | /** 72 | * The closest parent node that satisfies the predicate. 73 | */ 74 | node: Node; 75 | 76 | /** 77 | * The position directly before the node. 78 | */ 79 | pos: number; 80 | 81 | /** 82 | * The position at the start of the node. 83 | */ 84 | start: number; 85 | 86 | /** 87 | * The depth of the node. 88 | */ 89 | depth: number; 90 | } 91 | 92 | /** 93 | * Find the closest parent node that satisfies the predicate. 94 | * 95 | * @internal 96 | */ 97 | function findParentNode( 98 | /** 99 | * The predicate to test the parent node. 100 | */ 101 | predicate: (node: Node) => boolean, 102 | /** 103 | * The position to start searching from. 104 | */ 105 | $pos: ResolvedPos, 106 | ): FindNodeResult | null { 107 | for (let depth = $pos.depth; depth >= 0; depth -= 1) { 108 | const node = $pos.node(depth); 109 | 110 | if (predicate(node)) { 111 | const pos = depth === 0 ? 0 : $pos.before(depth); 112 | const start = $pos.start(depth); 113 | return { node, pos, start, depth }; 114 | } 115 | } 116 | 117 | return null; 118 | } 119 | -------------------------------------------------------------------------------- /src/tableview.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'prosemirror-model'; 2 | import type { NodeView, ViewMutationRecord } from 'prosemirror-view'; 3 | 4 | import type { CellAttrs } from './util'; 5 | 6 | /** 7 | * @public 8 | */ 9 | export class TableView implements NodeView { 10 | public dom: HTMLDivElement; 11 | public table: HTMLTableElement; 12 | public colgroup: HTMLTableColElement; 13 | public contentDOM: HTMLTableSectionElement; 14 | 15 | constructor( 16 | public node: Node, 17 | public defaultCellMinWidth: number, 18 | ) { 19 | this.dom = document.createElement('div'); 20 | this.dom.className = 'tableWrapper'; 21 | this.table = this.dom.appendChild(document.createElement('table')); 22 | this.table.style.setProperty( 23 | '--default-cell-min-width', 24 | `${defaultCellMinWidth}px`, 25 | ); 26 | this.colgroup = this.table.appendChild(document.createElement('colgroup')); 27 | updateColumnsOnResize(node, this.colgroup, this.table, defaultCellMinWidth); 28 | this.contentDOM = this.table.appendChild(document.createElement('tbody')); 29 | } 30 | 31 | update(node: Node): boolean { 32 | if (node.type != this.node.type) return false; 33 | this.node = node; 34 | updateColumnsOnResize( 35 | node, 36 | this.colgroup, 37 | this.table, 38 | this.defaultCellMinWidth, 39 | ); 40 | return true; 41 | } 42 | 43 | ignoreMutation(record: ViewMutationRecord): boolean { 44 | return ( 45 | record.type == 'attributes' && 46 | (record.target == this.table || this.colgroup.contains(record.target)) 47 | ); 48 | } 49 | } 50 | 51 | /** 52 | * @public 53 | */ 54 | export function updateColumnsOnResize( 55 | node: Node, 56 | colgroup: HTMLTableColElement, 57 | table: HTMLTableElement, 58 | defaultCellMinWidth: number, 59 | overrideCol?: number, 60 | overrideValue?: number, 61 | ): void { 62 | let totalWidth = 0; 63 | let fixedWidth = true; 64 | let nextDOM = colgroup.firstChild as HTMLElement; 65 | const row = node.firstChild; 66 | if (!row) return; 67 | 68 | for (let i = 0, col = 0; i < row.childCount; i++) { 69 | const { colspan, colwidth } = row.child(i).attrs as CellAttrs; 70 | for (let j = 0; j < colspan; j++, col++) { 71 | const hasWidth = 72 | overrideCol == col ? overrideValue : colwidth && colwidth[j]; 73 | const cssWidth = hasWidth ? hasWidth + 'px' : ''; 74 | totalWidth += hasWidth || defaultCellMinWidth; 75 | if (!hasWidth) fixedWidth = false; 76 | if (!nextDOM) { 77 | const col = document.createElement('col'); 78 | col.style.width = cssWidth; 79 | colgroup.appendChild(col); 80 | } else { 81 | if (nextDOM.style.width != cssWidth) { 82 | nextDOM.style.width = cssWidth; 83 | } 84 | nextDOM = nextDOM.nextSibling as HTMLElement; 85 | } 86 | } 87 | } 88 | 89 | while (nextDOM) { 90 | const after = nextDOM.nextSibling; 91 | nextDOM.parentNode?.removeChild(nextDOM); 92 | nextDOM = after as HTMLElement; 93 | } 94 | 95 | if (fixedWidth) { 96 | table.style.width = totalWidth + 'px'; 97 | table.style.minWidth = ''; 98 | } else { 99 | table.style.width = ''; 100 | table.style.minWidth = totalWidth + 'px'; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/convert.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'prosemirror-model'; 2 | 3 | import { TableMap } from '../tablemap'; 4 | 5 | /** 6 | * This function will transform the table node into a matrix of rows and columns 7 | * respecting merged cells, for example this table: 8 | * 9 | * ``` 10 | * ┌──────┬──────┬─────────────┐ 11 | * │ A1 │ B1 │ C1 │ 12 | * ├──────┼──────┴──────┬──────┤ 13 | * │ A2 │ B2 │ │ 14 | * ├──────┼─────────────┤ D1 │ 15 | * │ A3 │ B3 │ C3 │ │ 16 | * └──────┴──────┴──────┴──────┘ 17 | * ``` 18 | * 19 | * will be converted to the below: 20 | * 21 | * ```javascript 22 | * [ 23 | * [A1, B1, C1, null], 24 | * [A2, B2, null, D1], 25 | * [A3, B3, C3, null], 26 | * ] 27 | * ``` 28 | * @internal 29 | */ 30 | export function convertTableNodeToArrayOfRows( 31 | tableNode: Node, 32 | ): (Node | null)[][] { 33 | const map = TableMap.get(tableNode); 34 | const rows: (Node | null)[][] = []; 35 | const rowCount = map.height; 36 | const colCount = map.width; 37 | for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { 38 | const row: (Node | null)[] = []; 39 | for (let colIndex = 0; colIndex < colCount; colIndex++) { 40 | const cellIndex = rowIndex * colCount + colIndex; 41 | const cellPos = map.map[cellIndex]; 42 | if (rowIndex > 0) { 43 | const topCellIndex = cellIndex - colCount; 44 | const topCellPos = map.map[topCellIndex]; 45 | if (cellPos === topCellPos) { 46 | row.push(null); 47 | continue; 48 | } 49 | } 50 | if (colIndex > 0) { 51 | const leftCellIndex = cellIndex - 1; 52 | const leftCellPos = map.map[leftCellIndex]; 53 | if (cellPos === leftCellPos) { 54 | row.push(null); 55 | continue; 56 | } 57 | } 58 | row.push(tableNode.nodeAt(cellPos)); 59 | } 60 | rows.push(row); 61 | } 62 | 63 | return rows; 64 | } 65 | 66 | /** 67 | * Convert an array of rows to a table node. 68 | * 69 | * @internal 70 | */ 71 | export function convertArrayOfRowsToTableNode( 72 | tableNode: Node, 73 | arrayOfNodes: (Node | null)[][], 74 | ): Node { 75 | const newRows: Node[] = []; 76 | const map = TableMap.get(tableNode); 77 | const rowCount = map.height; 78 | const colCount = map.width; 79 | for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { 80 | const oldRow: Node = tableNode.child(rowIndex); 81 | const newCells: Node[] = []; 82 | 83 | for (let colIndex = 0; colIndex < colCount; colIndex++) { 84 | const cell = arrayOfNodes[rowIndex][colIndex]; 85 | if (!cell) { 86 | continue; 87 | } 88 | 89 | const cellPos = map.map[rowIndex * map.width + colIndex]; 90 | const oldCell = tableNode.nodeAt(cellPos); 91 | if (!oldCell) { 92 | continue; 93 | } 94 | 95 | const newCell = oldCell.type.createChecked( 96 | cell.attrs, 97 | cell.content, 98 | cell.marks, 99 | ); 100 | newCells.push(newCell); 101 | } 102 | 103 | const newRow = oldRow.type.createChecked( 104 | oldRow.attrs, 105 | newCells, 106 | oldRow.marks, 107 | ); 108 | newRows.push(newRow); 109 | } 110 | 111 | const newTable = tableNode.type.createChecked( 112 | tableNode.attrs, 113 | newRows, 114 | tableNode.marks, 115 | ); 116 | return newTable; 117 | } 118 | -------------------------------------------------------------------------------- /test/fixtable.test.ts: -------------------------------------------------------------------------------- 1 | import ist from 'ist'; 2 | import type { Node } from 'prosemirror-model'; 3 | import { EditorState } from 'prosemirror-state'; 4 | import { describe, it } from 'vitest'; 5 | 6 | import { fixTables } from '../src'; 7 | 8 | import { 9 | c, 10 | c11, 11 | cEmpty, 12 | doc, 13 | eq, 14 | h11, 15 | hEmpty, 16 | p, 17 | table, 18 | td, 19 | tr, 20 | } from './build'; 21 | 22 | const cw100 = td({ colwidth: [100] }, p('x')); 23 | const cw200 = td({ colwidth: [200] }, p('x')); 24 | 25 | function fix(node: Node) { 26 | const isDoc = node.type === node.type.schema.topNodeType; 27 | const state = EditorState.create({ doc: isDoc ? node : doc(node) }); 28 | const tr = fixTables(state); 29 | return tr && (isDoc ? tr.doc : tr.doc.firstChild)!; 30 | } 31 | 32 | describe('fixTable', () => { 33 | it("doesn't touch correct tables", () => { 34 | ist(fix(table(tr(c11, c11, c(1, 2)), tr(c11, c11))), null); 35 | }); 36 | 37 | it('adds trivially missing cells', () => { 38 | ist( 39 | fix(table(tr(c11, c11, c(1, 2)), tr(c11))), 40 | table(tr(c11, c11, c(1, 2)), tr(c11, cEmpty)), 41 | eq, 42 | ); 43 | }); 44 | 45 | it('can add to multiple rows', () => { 46 | ist( 47 | fix(table(tr(c11), tr(c11, c11), tr(c(3, 1)))), 48 | table(tr(c11, cEmpty, cEmpty), tr(cEmpty, c11, c11), tr(c(3, 1))), 49 | eq, 50 | ); 51 | }); 52 | 53 | it('will default to adding at the start of the first row', () => { 54 | ist( 55 | fix(table(tr(c11), tr(c11, c11))), 56 | table(tr(cEmpty, c11), tr(c11, c11)), 57 | eq, 58 | ); 59 | }); 60 | 61 | it('will default to adding at the end of the non-first row', () => { 62 | ist( 63 | fix(table(tr(c11, c11), tr(c11))), 64 | table(tr(c11, c11), tr(c11, cEmpty)), 65 | eq, 66 | ); 67 | }); 68 | 69 | it('will fix overlapping cells', () => { 70 | ist( 71 | fix(table(tr(c11, c(1, 2), c11), tr(c(2, 1)))), 72 | table(tr(c11, c(1, 2), c11), tr(c11, cEmpty, cEmpty)), 73 | eq, 74 | ); 75 | }); 76 | 77 | it('will fix a rowspan that sticks out of the table', () => { 78 | ist( 79 | fix(table(tr(c11, c11), tr(c(1, 2), c11))), 80 | table(tr(c11, c11), tr(c11, c11)), 81 | eq, 82 | ); 83 | }); 84 | 85 | it('makes sure column widths are coherent', () => { 86 | ist( 87 | fix(table(tr(c11, c11, cw200), tr(cw100, c11, c11))), 88 | table(tr(cw100, c11, cw200), tr(cw100, c11, cw200)), 89 | eq, 90 | ); 91 | }); 92 | 93 | it('can update column widths on colspan cells', () => { 94 | ist( 95 | fix(table(tr(c11, c11, cw200), tr(c(3, 2)), tr())), 96 | table( 97 | tr(c11, c11, cw200), 98 | tr(td({ colspan: 3, rowspan: 2, colwidth: [0, 0, 200] }, p('x'))), 99 | tr(), 100 | ), 101 | eq, 102 | ); 103 | }); 104 | 105 | it('will update the odd one out when column widths disagree', () => { 106 | ist( 107 | fix( 108 | table( 109 | tr(cw100, cw100, cw100), 110 | tr(cw200, cw200, cw100), 111 | tr(cw100, cw200, cw200), 112 | ), 113 | ), 114 | table( 115 | tr(cw100, cw200, cw100), 116 | tr(cw100, cw200, cw100), 117 | tr(cw100, cw200, cw100), 118 | ), 119 | eq, 120 | ); 121 | }); 122 | 123 | it('respects table role when inserting a cell', () => { 124 | ist( 125 | fix(table(tr(h11), tr(c11, c11), tr(c(3, 1)))), 126 | table(tr(h11, hEmpty, hEmpty), tr(cEmpty, c11, c11), tr(c(3, 1))), 127 | eq, 128 | ); 129 | }); 130 | 131 | it('will remove zero-sized table', () => { 132 | ist(fix(doc(table(tr()), table(tr(c11)))), doc(table(tr(c11))), eq); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /test/tablemap.test.ts: -------------------------------------------------------------------------------- 1 | import ist from 'ist'; 2 | import { describe, it } from 'vitest'; 3 | 4 | import { TableMap } from '../src'; 5 | import type { Rect } from '../src'; 6 | 7 | import { table, tr, c, c11 } from './build'; 8 | 9 | function eqRect(a: Rect, b: Rect) { 10 | return ( 11 | a.left == b.left && 12 | a.right == b.right && 13 | a.top == b.top && 14 | a.bottom == b.bottom 15 | ); 16 | } 17 | 18 | describe('TableMap', () => { 19 | it('finds the right shape for a simple table', () => { 20 | ist( 21 | TableMap.get( 22 | table( 23 | tr(c11, c11, c11), 24 | tr(c11, c11, c11), 25 | tr(c11, c11, c11), 26 | tr(c11, c11, c11), 27 | ), 28 | ).map.join(', '), 29 | '1, 6, 11, 18, 23, 28, 35, 40, 45, 52, 57, 62', 30 | ); 31 | }); 32 | 33 | it('finds the right shape for colspans', () => { 34 | ist( 35 | TableMap.get( 36 | table(tr(c11, c(2, 1)), tr(c(2, 1), c11), tr(c11, c11, c11)), 37 | ).map.join(', '), 38 | '1, 6, 6, 13, 13, 18, 25, 30, 35', 39 | ); 40 | }); 41 | 42 | it('finds the right shape for rowspans', () => { 43 | ist( 44 | TableMap.get(table(tr(c(1, 2), c11, c(1, 2)), tr(c11))).map.join(', '), 45 | '1, 6, 11, 1, 18, 11', 46 | ); 47 | }); 48 | 49 | it('finds the right shape for deep rowspans', () => { 50 | ist( 51 | TableMap.get( 52 | table(tr(c(1, 4), c(2, 1)), tr(c(1, 2), c(1, 2)), tr()), 53 | ).map.join(', '), 54 | '1, 6, 6, 1, 13, 18, 1, 13, 18', 55 | ); 56 | }); 57 | 58 | it('finds the right shape for larger rectangles', () => { 59 | ist( 60 | TableMap.get(table(tr(c11, c(4, 4)), tr(c11), tr(c11), tr(c11))).map.join( 61 | ', ', 62 | ), 63 | '1, 6, 6, 6, 6, 13, 6, 6, 6, 6, 20, 6, 6, 6, 6, 27, 6, 6, 6, 6', 64 | ); 65 | }); 66 | 67 | const map = TableMap.get( 68 | table(tr(c(2, 3), c11, c(1, 2)), tr(c11), tr(c(2, 1))), 69 | ); 70 | // 1 1 6 11 71 | // 1 1 18 11 72 | // 1 1 25 25 73 | 74 | it('can accurately find cell sizes', () => { 75 | ist(map.width, 4); 76 | ist(map.height, 3); 77 | ist(map.findCell(1), { left: 0, right: 2, top: 0, bottom: 3 }, eqRect); 78 | ist(map.findCell(6), { left: 2, right: 3, top: 0, bottom: 1 }, eqRect); 79 | ist(map.findCell(11), { left: 3, right: 4, top: 0, bottom: 2 }, eqRect); 80 | ist(map.findCell(18), { left: 2, right: 3, top: 1, bottom: 2 }, eqRect); 81 | ist(map.findCell(25), { left: 2, right: 4, top: 2, bottom: 3 }, eqRect); 82 | }); 83 | 84 | it('can find the rectangle between two cells', () => { 85 | ist(map.cellsInRect(map.rectBetween(1, 6)).join(', '), '1, 6, 18, 25'); 86 | ist(map.cellsInRect(map.rectBetween(1, 25)).join(', '), '1, 6, 11, 18, 25'); 87 | ist(map.cellsInRect(map.rectBetween(1, 1)).join(', '), '1'); 88 | ist(map.cellsInRect(map.rectBetween(6, 25)).join(', '), '6, 11, 18, 25'); 89 | ist(map.cellsInRect(map.rectBetween(6, 11)).join(', '), '6, 11, 18'); 90 | ist(map.cellsInRect(map.rectBetween(11, 6)).join(', '), '6, 11, 18'); 91 | ist(map.cellsInRect(map.rectBetween(18, 25)).join(', '), '18, 25'); 92 | ist(map.cellsInRect(map.rectBetween(6, 18)).join(', '), '6, 18'); 93 | }); 94 | 95 | it('can find adjacent cells', () => { 96 | ist(map.nextCell(1, 'horiz', 1), 6); 97 | ist(map.nextCell(1, 'horiz', -1), null); 98 | ist(map.nextCell(1, 'vert', 1), null); 99 | ist(map.nextCell(1, 'vert', -1), null); 100 | 101 | ist(map.nextCell(18, 'horiz', 1), 11); 102 | ist(map.nextCell(18, 'horiz', -1), 1); 103 | ist(map.nextCell(18, 'vert', 1), 25); 104 | ist(map.nextCell(18, 'vert', -1), 6); 105 | 106 | ist(map.nextCell(25, 'vert', 1), null); 107 | ist(map.nextCell(25, 'vert', -1), 18); 108 | ist(map.nextCell(25, 'horiz', 1), null); 109 | ist(map.nextCell(25, 'horiz', -1), 1); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /demo/demo.ts: -------------------------------------------------------------------------------- 1 | import 'prosemirror-view/style/prosemirror.css'; 2 | import 'prosemirror-menu/style/menu.css'; 3 | import 'prosemirror-example-setup/style/style.css'; 4 | import 'prosemirror-gapcursor/style/gapcursor.css'; 5 | import '../style/tables.css'; 6 | 7 | import { exampleSetup, buildMenuItems } from 'prosemirror-example-setup'; 8 | import { keymap } from 'prosemirror-keymap'; 9 | import { MenuItem, Dropdown } from 'prosemirror-menu'; 10 | import { DOMParser, Schema } from 'prosemirror-model'; 11 | import { schema as baseSchema } from 'prosemirror-schema-basic'; 12 | import { EditorState } from 'prosemirror-state'; 13 | import { EditorView } from 'prosemirror-view'; 14 | 15 | import { 16 | addColumnAfter, 17 | addColumnBefore, 18 | deleteColumn, 19 | addRowAfter, 20 | addRowBefore, 21 | deleteRow, 22 | mergeCells, 23 | splitCell, 24 | setCellAttr, 25 | toggleHeaderRow, 26 | toggleHeaderColumn, 27 | toggleHeaderCell, 28 | goToNextCell, 29 | deleteTable, 30 | } from '../src'; 31 | import { tableEditing, columnResizing, tableNodes, fixTables } from '../src'; 32 | 33 | const schema = new Schema({ 34 | nodes: baseSchema.spec.nodes.append( 35 | tableNodes({ 36 | tableGroup: 'block', 37 | cellContent: 'block+', 38 | cellAttributes: { 39 | background: { 40 | default: null, 41 | getFromDOM(dom) { 42 | return dom.style.backgroundColor || null; 43 | }, 44 | setDOMAttr(value, attrs) { 45 | if (value) { 46 | attrs.style = [ 47 | `background-color: ${value as string}`, 48 | attrs.style, 49 | ] 50 | .filter(Boolean) 51 | .map(String) 52 | .join('; '); 53 | } 54 | }, 55 | }, 56 | }, 57 | }), 58 | ), 59 | marks: baseSchema.spec.marks, 60 | }); 61 | 62 | const menu = buildMenuItems(schema).fullMenu; 63 | function item(label: string, cmd: (state: EditorState) => boolean) { 64 | return new MenuItem({ label, select: cmd, run: cmd }); 65 | } 66 | const tableMenu = [ 67 | item('Insert column before', addColumnBefore), 68 | item('Insert column after', addColumnAfter), 69 | item('Delete column', deleteColumn), 70 | item('Insert row before', addRowBefore), 71 | item('Insert row after', addRowAfter), 72 | item('Delete row', deleteRow), 73 | item('Delete table', deleteTable), 74 | item('Merge cells', mergeCells), 75 | item('Split cell', splitCell), 76 | item('Toggle header column', toggleHeaderColumn), 77 | item('Toggle header row', toggleHeaderRow), 78 | item('Toggle header cells', toggleHeaderCell), 79 | item('Make cell green', setCellAttr('background', '#dfd')), 80 | item('Make cell not-green', setCellAttr('background', null)), 81 | ]; 82 | menu.splice(2, 0, [new Dropdown(tableMenu, { label: 'Table' })]); 83 | 84 | const contentElement = document.querySelector('#content'); 85 | if (!contentElement) { 86 | throw new Error('Failed to find #content'); 87 | } 88 | const doc = DOMParser.fromSchema(schema).parse(contentElement); 89 | 90 | let state = EditorState.create({ 91 | doc, 92 | plugins: [ 93 | columnResizing(), 94 | tableEditing(), 95 | keymap({ 96 | Tab: goToNextCell(1), 97 | 'Shift-Tab': goToNextCell(-1), 98 | }), 99 | ].concat( 100 | exampleSetup({ 101 | schema, 102 | menuContent: menu, 103 | }), 104 | ), 105 | }); 106 | const fix = fixTables(state); 107 | if (fix) state = state.apply(fix.setMeta('addToHistory', false)); 108 | 109 | const view = new EditorView(document.querySelector('#editor'), { 110 | state, 111 | }); 112 | 113 | declare global { 114 | interface Window { 115 | view?: EditorView; 116 | } 117 | } 118 | 119 | window.view = view; 120 | 121 | document.execCommand('enableObjectResizing', false, 'false'); 122 | document.execCommand('enableInlineTableEditing', false, 'false'); 123 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // This file defines a plugin that handles the drawing of cell 2 | // selections and the basic user interactions for creating and working 3 | // with such selections. It also makes sure that, after each 4 | // transaction, the shapes of tables are normalized to be rectangular 5 | // and not contain overlapping cells. 6 | 7 | import { Plugin } from 'prosemirror-state'; 8 | 9 | import { drawCellSelection, normalizeSelection } from './cellselection'; 10 | import { fixTables, fixTablesKey } from './fixtables'; 11 | import { 12 | handleKeyDown, 13 | handleMouseDown, 14 | handlePaste, 15 | handleTripleClick, 16 | } from './input'; 17 | import { tableEditingKey } from './util'; 18 | 19 | export { CellBookmark, CellSelection } from './cellselection'; 20 | export type { CellSelectionJSON } from './cellselection'; 21 | export { 22 | columnResizing, 23 | columnResizingPluginKey, 24 | ResizeState, 25 | } from './columnresizing'; 26 | export type { ColumnResizingOptions, Dragging } from './columnresizing'; 27 | export * from './commands'; 28 | export { 29 | clipCells as __clipCells, 30 | insertCells as __insertCells, 31 | pastedCells as __pastedCells, 32 | } from './copypaste'; 33 | export type { Area as __Area } from './copypaste'; 34 | export type { Direction } from './input'; 35 | export { tableNodes, tableNodeTypes } from './schema'; 36 | export type { 37 | CellAttributes, 38 | getFromDOM, 39 | setDOMAttr, 40 | TableNodes, 41 | TableNodesOptions, 42 | TableRole, 43 | } from './schema'; 44 | export { TableMap } from './tablemap'; 45 | export type { ColWidths, Problem, Rect } from './tablemap'; 46 | export { TableView, updateColumnsOnResize } from './tableview'; 47 | export { 48 | addColSpan, 49 | cellAround, 50 | cellNear, 51 | colCount, 52 | columnIsHeader, 53 | findCell, 54 | inSameTable, 55 | isInTable, 56 | moveCellForward, 57 | nextCell, 58 | pointsAtCell, 59 | removeColSpan, 60 | selectionCell, 61 | } from './util'; 62 | export type { MutableAttrs } from './util'; 63 | export { findCellPos, findCellRange, findTable } from './utils/query'; 64 | export type { FindNodeResult } from './utils/query'; 65 | export { fixTables, fixTablesKey, handlePaste, tableEditingKey }; 66 | 67 | /** 68 | * @public 69 | */ 70 | export type TableEditingOptions = { 71 | /** 72 | * Whether to allow table node selection. 73 | * 74 | * By default, any node selection wrapping a table will be converted into a 75 | * CellSelection wrapping all cells in the table. You can pass `true` to allow 76 | * the selection to remain a NodeSelection. 77 | * 78 | * @default false 79 | */ 80 | allowTableNodeSelection?: boolean; 81 | }; 82 | 83 | /** 84 | * Creates a [plugin](http://prosemirror.net/docs/ref/#state.Plugin) 85 | * that, when added to an editor, enables cell-selection, handles 86 | * cell-based copy/paste, and makes sure tables stay well-formed (each 87 | * row has the same width, and cells don't overlap). 88 | * 89 | * You should probably put this plugin near the end of your array of 90 | * plugins, since it handles mouse and arrow key events in tables 91 | * rather broadly, and other plugins, like the gap cursor or the 92 | * column-width dragging plugin, might want to get a turn first to 93 | * perform more specific behavior. 94 | * 95 | * @public 96 | */ 97 | export function tableEditing({ 98 | allowTableNodeSelection = false, 99 | }: TableEditingOptions = {}): Plugin { 100 | return new Plugin({ 101 | key: tableEditingKey, 102 | 103 | // This piece of state is used to remember when a mouse-drag 104 | // cell-selection is happening, so that it can continue even as 105 | // transactions (which might move its anchor cell) come in. 106 | state: { 107 | init() { 108 | return null; 109 | }, 110 | apply(tr, cur) { 111 | const set = tr.getMeta(tableEditingKey); 112 | if (set != null) return set == -1 ? null : set; 113 | if (cur == null || !tr.docChanged) return cur; 114 | const { deleted, pos } = tr.mapping.mapResult(cur); 115 | return deleted ? null : pos; 116 | }, 117 | }, 118 | 119 | props: { 120 | decorations: drawCellSelection, 121 | 122 | handleDOMEvents: { 123 | mousedown: handleMouseDown, 124 | }, 125 | 126 | createSelectionBetween(view) { 127 | return tableEditingKey.getState(view.state) != null 128 | ? view.state.selection 129 | : null; 130 | }, 131 | 132 | handleTripleClick, 133 | 134 | handleKeyDown, 135 | 136 | handlePaste, 137 | }, 138 | 139 | appendTransaction(_, oldState, state) { 140 | return normalizeSelection( 141 | state, 142 | fixTables(state, oldState), 143 | allowTableNodeSelection, 144 | ); 145 | }, 146 | }); 147 | } 148 | -------------------------------------------------------------------------------- /test/move-row-in-array-of-rows.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { moveRowInArrayOfRows } from '../src/utils/move-row-in-array-of-rows'; 4 | 5 | import { td } from './build'; 6 | 7 | describe('moveRowInArrayOfRows', () => { 8 | describe('single element moves', () => { 9 | it('should move element down (forward)', () => { 10 | const rows = [0, 1, 2, 3, 4]; 11 | const result = moveRowInArrayOfRows(rows, [1], [3], 0); 12 | expect(result).toEqual([0, 2, 3, 1, 4]); 13 | }); 14 | 15 | it('should move element up (backward)', () => { 16 | const rows = [0, 1, 2, 3, 4]; 17 | const result = moveRowInArrayOfRows(rows, [3], [1], 0); 18 | expect(result).toEqual([0, 3, 1, 2, 4]); 19 | }); 20 | 21 | it('should move first element to end', () => { 22 | const rows = [0, 1, 2, 3]; 23 | const result = moveRowInArrayOfRows(rows, [0], [3], 0); 24 | expect(result).toEqual([1, 2, 3, 0]); 25 | }); 26 | 27 | it('should move last element to beginning', () => { 28 | const rows = [0, 1, 2, 3]; 29 | const result = moveRowInArrayOfRows(rows, [3], [0], 0); 30 | expect(result).toEqual([3, 0, 1, 2]); 31 | }); 32 | }); 33 | 34 | describe('multiple element moves', () => { 35 | it('should move two consecutive elements down', () => { 36 | const rows = [0, 1, 2, 3, 4, 5]; 37 | const result = moveRowInArrayOfRows(rows, [1, 2], [4, 5], 0); 38 | expect(result).toEqual([0, 3, 4, 5, 1, 2]); 39 | }); 40 | 41 | it('should move two consecutive elements up', () => { 42 | const rows = [0, 1, 2, 3, 4, 5]; 43 | const result = moveRowInArrayOfRows(rows, [4, 5], [1, 2], 0); 44 | expect(result).toEqual([0, 4, 5, 1, 2, 3]); 45 | }); 46 | 47 | it('should move three elements', () => { 48 | const rows = [0, 1, 2, 3, 4, 5, 6]; 49 | const result = moveRowInArrayOfRows(rows, [1, 2, 3], [5, 6], 0); 50 | expect(result).toEqual([0, 4, 5, 6, 1, 2, 3]); 51 | }); 52 | }); 53 | 54 | describe('direction overrides', () => { 55 | it('should handle override -1 (force before target)', () => { 56 | const rows = [0, 1, 2, 3, 4, 5]; 57 | const result = moveRowInArrayOfRows(rows, [1], [4], -1); 58 | expect(result).toEqual([0, 2, 3, 1, 4, 5]); 59 | }); 60 | 61 | it('should handle override 0 (natural direction)', () => { 62 | const rows = [0, 1, 2, 3, 4, 5]; 63 | const result = moveRowInArrayOfRows(rows, [1], [4], 0); 64 | expect(result).toEqual([0, 2, 3, 4, 1, 5]); 65 | }); 66 | 67 | it('should handle override +1 (force after target)', () => { 68 | const rows = [0, 1, 2, 3, 4]; 69 | const result = moveRowInArrayOfRows(rows, [3], [1], 1); 70 | expect(result).toEqual([0, 1, 3, 2, 4]); 71 | }); 72 | }); 73 | 74 | describe('edge cases', () => { 75 | it('should handle single element array', () => { 76 | const rows = [0]; 77 | const result = moveRowInArrayOfRows(rows, [0], [0], 0); 78 | expect(result).toEqual([0]); 79 | }); 80 | 81 | it('should handle two element array', () => { 82 | const rows = [0, 1]; 83 | const result = moveRowInArrayOfRows(rows, [0], [1], 0); 84 | expect(result).toEqual([1, 0]); 85 | }); 86 | 87 | it('should handle moving to same position', () => { 88 | const rows = [0, 1, 2, 3]; 89 | const result = moveRowInArrayOfRows(rows, [2], [2], 0); 90 | expect(result).toEqual([0, 1, 2, 3]); 91 | }); 92 | 93 | it('should handle adjacent elements', () => { 94 | const rows = [0, 1, 2, 3]; 95 | const result = moveRowInArrayOfRows(rows, [1], [2], 0); 96 | expect(result).toEqual([0, 2, 1, 3]); 97 | }); 98 | }); 99 | 100 | describe('data types', () => { 101 | it('should work with strings', () => { 102 | const rows = ['a', 'b', 'c', 'd']; 103 | const result = moveRowInArrayOfRows(rows, [0], [2], 0); 104 | expect(result).toEqual(['b', 'c', 'a', 'd']); 105 | }); 106 | 107 | it('should work with mixed types', () => { 108 | const rows = [1, 'a', true, null, 4]; 109 | const result = moveRowInArrayOfRows(rows, [1], [3], 0); 110 | expect(result).toEqual([1, true, null, 'a', 4]); 111 | }); 112 | 113 | it('should work with table cell nodes', () => { 114 | const rows = [ 115 | [td('0'), td('A')], 116 | [td('1'), td('B')], 117 | [td('2'), td('C')], 118 | ]; 119 | 120 | const result = moveRowInArrayOfRows(rows, [2], [0], 0); 121 | expect(result[0][0]?.textContent).toBe('2'); 122 | expect(result[1][0]?.textContent).toBe('0'); 123 | expect(result[2][0]?.textContent).toBe('1'); 124 | }); 125 | }); 126 | 127 | describe('complex scenarios', () => { 128 | it('should handle large arrays', () => { 129 | const rows = Array.from({ length: 10 }, (_, i) => i); // [0,1,2,3,4,5,6,7,8,9] 130 | const result = moveRowInArrayOfRows(rows, [2, 3, 4], [7, 8, 9], 0); 131 | expect(result).toEqual([0, 1, 5, 6, 7, 8, 9, 2, 3, 4]); 132 | }); 133 | 134 | it('should handle moving entire beginning to end', () => { 135 | const rows = [0, 1, 2, 3, 4]; 136 | const result = moveRowInArrayOfRows(rows, [0, 1, 2], [4], 0); 137 | expect(result).toEqual([3, 4, 0, 1, 2]); 138 | }); 139 | 140 | it('should handle moving entire end to beginning', () => { 141 | const rows = [0, 1, 2, 3, 4]; 142 | const result = moveRowInArrayOfRows(rows, [3, 4], [0, 1], 0); 143 | expect(result).toEqual([3, 4, 0, 1, 2]); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | // Various helper function for working with tables 2 | 3 | import type { Attrs, Node, ResolvedPos } from 'prosemirror-model'; 4 | import type { EditorState, NodeSelection } from 'prosemirror-state'; 5 | import { PluginKey } from 'prosemirror-state'; 6 | 7 | import type { CellSelection } from './cellselection'; 8 | import { tableNodeTypes } from './schema'; 9 | import type { Rect } from './tablemap'; 10 | import { TableMap } from './tablemap'; 11 | 12 | /** 13 | * @public 14 | */ 15 | export type MutableAttrs = Record; 16 | 17 | /** 18 | * @public 19 | */ 20 | export interface CellAttrs { 21 | colspan: number; 22 | rowspan: number; 23 | colwidth: number[] | null; 24 | } 25 | 26 | /** 27 | * @public 28 | */ 29 | export const tableEditingKey = new PluginKey('selectingCells'); 30 | 31 | /** 32 | * @public 33 | */ 34 | export function cellAround($pos: ResolvedPos): ResolvedPos | null { 35 | for (let d = $pos.depth - 1; d > 0; d--) 36 | if ($pos.node(d).type.spec.tableRole == 'row') 37 | return $pos.node(0).resolve($pos.before(d + 1)); 38 | return null; 39 | } 40 | 41 | export function cellWrapping($pos: ResolvedPos): null | Node { 42 | for (let d = $pos.depth; d > 0; d--) { 43 | // Sometimes the cell can be in the same depth. 44 | const role = $pos.node(d).type.spec.tableRole; 45 | if (role === 'cell' || role === 'header_cell') return $pos.node(d); 46 | } 47 | return null; 48 | } 49 | 50 | /** 51 | * @public 52 | */ 53 | export function isInTable(state: EditorState): boolean { 54 | const $head = state.selection.$head; 55 | for (let d = $head.depth; d > 0; d--) 56 | if ($head.node(d).type.spec.tableRole == 'row') return true; 57 | return false; 58 | } 59 | 60 | /** 61 | * @internal 62 | */ 63 | export function selectionCell(state: EditorState): ResolvedPos { 64 | const sel = state.selection as CellSelection | NodeSelection; 65 | if ('$anchorCell' in sel && sel.$anchorCell) { 66 | return sel.$anchorCell.pos > sel.$headCell.pos 67 | ? sel.$anchorCell 68 | : sel.$headCell; 69 | } else if ( 70 | 'node' in sel && 71 | sel.node && 72 | sel.node.type.spec.tableRole == 'cell' 73 | ) { 74 | return sel.$anchor; 75 | } 76 | const $cell = cellAround(sel.$head) || cellNear(sel.$head); 77 | if ($cell) { 78 | return $cell; 79 | } 80 | throw new RangeError(`No cell found around position ${sel.head}`); 81 | } 82 | 83 | /** 84 | * @public 85 | */ 86 | export function cellNear($pos: ResolvedPos): ResolvedPos | undefined { 87 | for ( 88 | let after = $pos.nodeAfter, pos = $pos.pos; 89 | after; 90 | after = after.firstChild, pos++ 91 | ) { 92 | const role = after.type.spec.tableRole; 93 | if (role == 'cell' || role == 'header_cell') return $pos.doc.resolve(pos); 94 | } 95 | for ( 96 | let before = $pos.nodeBefore, pos = $pos.pos; 97 | before; 98 | before = before.lastChild, pos-- 99 | ) { 100 | const role = before.type.spec.tableRole; 101 | if (role == 'cell' || role == 'header_cell') 102 | return $pos.doc.resolve(pos - before.nodeSize); 103 | } 104 | } 105 | 106 | /** 107 | * @public 108 | */ 109 | export function pointsAtCell($pos: ResolvedPos): boolean { 110 | return $pos.parent.type.spec.tableRole == 'row' && !!$pos.nodeAfter; 111 | } 112 | 113 | /** 114 | * @public 115 | */ 116 | export function moveCellForward($pos: ResolvedPos): ResolvedPos { 117 | return $pos.node(0).resolve($pos.pos + $pos.nodeAfter!.nodeSize); 118 | } 119 | 120 | /** 121 | * @internal 122 | */ 123 | export function inSameTable($cellA: ResolvedPos, $cellB: ResolvedPos): boolean { 124 | return ( 125 | $cellA.depth == $cellB.depth && 126 | $cellA.pos >= $cellB.start(-1) && 127 | $cellA.pos <= $cellB.end(-1) 128 | ); 129 | } 130 | 131 | /** 132 | * @public 133 | */ 134 | export function findCell($pos: ResolvedPos): Rect { 135 | return TableMap.get($pos.node(-1)).findCell($pos.pos - $pos.start(-1)); 136 | } 137 | 138 | /** 139 | * @public 140 | */ 141 | export function colCount($pos: ResolvedPos): number { 142 | return TableMap.get($pos.node(-1)).colCount($pos.pos - $pos.start(-1)); 143 | } 144 | 145 | /** 146 | * @public 147 | */ 148 | export function nextCell( 149 | $pos: ResolvedPos, 150 | axis: 'horiz' | 'vert', 151 | dir: number, 152 | ): ResolvedPos | null { 153 | const table = $pos.node(-1); 154 | const map = TableMap.get(table); 155 | const tableStart = $pos.start(-1); 156 | 157 | const moved = map.nextCell($pos.pos - tableStart, axis, dir); 158 | return moved == null ? null : $pos.node(0).resolve(tableStart + moved); 159 | } 160 | 161 | /** 162 | * @public 163 | */ 164 | export function removeColSpan(attrs: CellAttrs, pos: number, n = 1): CellAttrs { 165 | const result: CellAttrs = { ...attrs, colspan: attrs.colspan - n }; 166 | 167 | if (result.colwidth) { 168 | result.colwidth = result.colwidth.slice(); 169 | result.colwidth.splice(pos, n); 170 | if (!result.colwidth.some((w) => w > 0)) result.colwidth = null; 171 | } 172 | return result; 173 | } 174 | 175 | /** 176 | * @public 177 | */ 178 | export function addColSpan(attrs: CellAttrs, pos: number, n = 1): Attrs { 179 | const result = { ...attrs, colspan: attrs.colspan + n }; 180 | if (result.colwidth) { 181 | result.colwidth = result.colwidth.slice(); 182 | for (let i = 0; i < n; i++) result.colwidth.splice(pos, 0, 0); 183 | } 184 | return result; 185 | } 186 | 187 | /** 188 | * @public 189 | */ 190 | export function columnIsHeader( 191 | map: TableMap, 192 | table: Node, 193 | col: number, 194 | ): boolean { 195 | const headerCell = tableNodeTypes(table.type.schema).header_cell; 196 | for (let row = 0; row < map.height; row++) 197 | if (table.nodeAt(map.map[col + row * map.width])!.type != headerCell) 198 | return false; 199 | return true; 200 | } 201 | -------------------------------------------------------------------------------- /src/fixtables.ts: -------------------------------------------------------------------------------- 1 | // This file defines helpers for normalizing tables, making sure no 2 | // cells overlap (which can happen, if you have the wrong col- and 3 | // rowspans) and that each row has the same width. Uses the problems 4 | // reported by `TableMap`. 5 | 6 | import type { Node } from 'prosemirror-model'; 7 | import type { EditorState, Transaction } from 'prosemirror-state'; 8 | import { PluginKey } from 'prosemirror-state'; 9 | 10 | import type { TableRole } from './schema'; 11 | import { tableNodeTypes } from './schema'; 12 | import { TableMap } from './tablemap'; 13 | import type { CellAttrs } from './util'; 14 | import { removeColSpan } from './util'; 15 | 16 | /** 17 | * @public 18 | */ 19 | export const fixTablesKey = new PluginKey<{ fixTables: boolean }>('fix-tables'); 20 | 21 | /** 22 | * Helper for iterating through the nodes in a document that changed 23 | * compared to the given previous document. Useful for avoiding 24 | * duplicate work on each transaction. 25 | * 26 | * @public 27 | */ 28 | function changedDescendants( 29 | old: Node, 30 | cur: Node, 31 | offset: number, 32 | f: (node: Node, pos: number) => void, 33 | ): void { 34 | const oldSize = old.childCount, 35 | curSize = cur.childCount; 36 | outer: for (let i = 0, j = 0; i < curSize; i++) { 37 | const child = cur.child(i); 38 | for (let scan = j, e = Math.min(oldSize, i + 3); scan < e; scan++) { 39 | if (old.child(scan) == child) { 40 | j = scan + 1; 41 | offset += child.nodeSize; 42 | continue outer; 43 | } 44 | } 45 | f(child, offset); 46 | if (j < oldSize && old.child(j).sameMarkup(child)) 47 | changedDescendants(old.child(j), child, offset + 1, f); 48 | else child.nodesBetween(0, child.content.size, f, offset + 1); 49 | offset += child.nodeSize; 50 | } 51 | } 52 | 53 | /** 54 | * Inspect all tables in the given state's document and return a 55 | * transaction that fixes them, if necessary. If `oldState` was 56 | * provided, that is assumed to hold a previous, known-good state, 57 | * which will be used to avoid re-scanning unchanged parts of the 58 | * document. 59 | * 60 | * @public 61 | */ 62 | export function fixTables( 63 | state: EditorState, 64 | oldState?: EditorState, 65 | ): Transaction | undefined { 66 | let tr: Transaction | undefined; 67 | const check = (node: Node, pos: number) => { 68 | if (node.type.spec.tableRole == 'table') 69 | tr = fixTable(state, node, pos, tr); 70 | }; 71 | if (!oldState) state.doc.descendants(check); 72 | else if (oldState.doc != state.doc) 73 | changedDescendants(oldState.doc, state.doc, 0, check); 74 | return tr; 75 | } 76 | 77 | // Fix the given table, if necessary. Will append to the transaction 78 | // it was given, if non-null, or create a new one if necessary. 79 | export function fixTable( 80 | state: EditorState, 81 | table: Node, 82 | tablePos: number, 83 | tr: Transaction | undefined, 84 | ): Transaction | undefined { 85 | const map = TableMap.get(table); 86 | if (!map.problems) return tr; 87 | if (!tr) tr = state.tr; 88 | 89 | // Track which rows we must add cells to, so that we can adjust that 90 | // when fixing collisions. 91 | const mustAdd: number[] = []; 92 | for (let i = 0; i < map.height; i++) mustAdd.push(0); 93 | for (let i = 0; i < map.problems.length; i++) { 94 | const prob = map.problems[i]; 95 | if (prob.type == 'collision') { 96 | const cell = table.nodeAt(prob.pos); 97 | if (!cell) continue; 98 | const attrs = cell.attrs as CellAttrs; 99 | for (let j = 0; j < attrs.rowspan; j++) mustAdd[prob.row + j] += prob.n; 100 | tr.setNodeMarkup( 101 | tr.mapping.map(tablePos + 1 + prob.pos), 102 | null, 103 | removeColSpan(attrs, attrs.colspan - prob.n, prob.n), 104 | ); 105 | } else if (prob.type == 'missing') { 106 | mustAdd[prob.row] += prob.n; 107 | } else if (prob.type == 'overlong_rowspan') { 108 | const cell = table.nodeAt(prob.pos); 109 | if (!cell) continue; 110 | tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, { 111 | ...cell.attrs, 112 | rowspan: cell.attrs.rowspan - prob.n, 113 | }); 114 | } else if (prob.type == 'colwidth mismatch') { 115 | const cell = table.nodeAt(prob.pos); 116 | if (!cell) continue; 117 | tr.setNodeMarkup(tr.mapping.map(tablePos + 1 + prob.pos), null, { 118 | ...cell.attrs, 119 | colwidth: prob.colwidth, 120 | }); 121 | } else if (prob.type == 'zero_sized') { 122 | const pos = tr.mapping.map(tablePos); 123 | tr.delete(pos, pos + table.nodeSize); 124 | } 125 | } 126 | let first, last; 127 | for (let i = 0; i < mustAdd.length; i++) 128 | if (mustAdd[i]) { 129 | if (first == null) first = i; 130 | last = i; 131 | } 132 | // Add the necessary cells, using a heuristic for whether to add the 133 | // cells at the start or end of the rows (if it looks like a 'bite' 134 | // was taken out of the table, add cells at the start of the row 135 | // after the bite. Otherwise add them at the end). 136 | for (let i = 0, pos = tablePos + 1; i < map.height; i++) { 137 | const row = table.child(i); 138 | const end = pos + row.nodeSize; 139 | const add = mustAdd[i]; 140 | if (add > 0) { 141 | let role: TableRole = 'cell'; 142 | if (row.firstChild) { 143 | role = row.firstChild.type.spec.tableRole; 144 | } 145 | const nodes: Node[] = []; 146 | for (let j = 0; j < add; j++) { 147 | const node = tableNodeTypes(state.schema)[role].createAndFill(); 148 | 149 | if (node) nodes.push(node); 150 | } 151 | const side = (i == 0 || first == i - 1) && last == i ? pos + 1 : end - 1; 152 | tr.insert(tr.mapping.map(side), nodes); 153 | } 154 | pos = end; 155 | } 156 | return tr.setMeta(fixTablesKey, { fixTables: true }); 157 | } 158 | -------------------------------------------------------------------------------- /test/cellselection.test.ts: -------------------------------------------------------------------------------- 1 | import ist from 'ist'; 2 | import type { Node } from 'prosemirror-model'; 3 | import { Slice } from 'prosemirror-model'; 4 | import type { Command, Selection } from 'prosemirror-state'; 5 | import { EditorState, NodeSelection } from 'prosemirror-state'; 6 | import { describe, it } from 'vitest'; 7 | 8 | import { 9 | addColumnAfter, 10 | addColumnBefore, 11 | addRowAfter, 12 | addRowBefore, 13 | CellSelection, 14 | tableEditing, 15 | } from '../src'; 16 | 17 | import { 18 | c, 19 | c11, 20 | cAnchor, 21 | cEmpty, 22 | cHead, 23 | doc, 24 | eq, 25 | p, 26 | selectionFor, 27 | table, 28 | td, 29 | tr, 30 | } from './build'; 31 | 32 | describe('CellSelection', () => { 33 | const t = doc( 34 | table( 35 | tr(/* 2*/ cEmpty, /* 6*/ cEmpty, /*10*/ cEmpty), 36 | tr(/*16*/ cEmpty, /*20*/ cEmpty, /*24*/ cEmpty), 37 | tr(/*30*/ cEmpty, /*34*/ cEmpty, /*36*/ cEmpty), 38 | ), 39 | ); 40 | 41 | function run(anchor: number, head: number, command: Command): EditorState { 42 | let state = EditorState.create({ 43 | doc: t, 44 | selection: CellSelection.create(t, anchor, head), 45 | }); 46 | command(state, (tr) => (state = state.apply(tr))); 47 | return state; 48 | } 49 | 50 | it('will put its head/anchor around the head cell', () => { 51 | let s = CellSelection.create(t, 2, 24); 52 | ist(s.anchor, 25); 53 | ist(s.head, 27); 54 | s = CellSelection.create(t, 24, 2); 55 | ist(s.anchor, 3); 56 | ist(s.head, 5); 57 | s = CellSelection.create(t, 10, 30); 58 | ist(s.anchor, 31); 59 | ist(s.head, 33); 60 | s = CellSelection.create(t, 30, 10); 61 | ist(s.anchor, 11); 62 | ist(s.head, 13); 63 | }); 64 | 65 | it('extends a row selection when adding a row', () => { 66 | let sel = run(34, 6, addRowBefore).selection as CellSelection; 67 | ist(sel.$anchorCell.pos, 48); 68 | ist(sel.$headCell.pos, 6); 69 | sel = run(6, 30, addRowAfter).selection as CellSelection; 70 | ist(sel.$anchorCell.pos, 6); 71 | ist(sel.$headCell.pos, 44); 72 | }); 73 | 74 | it('extends a col selection when adding a column', () => { 75 | let sel = run(16, 24, addColumnAfter).selection as CellSelection; 76 | ist(sel.$anchorCell.pos, 20); 77 | ist(sel.$headCell.pos, 32); 78 | sel = run(24, 30, addColumnBefore).selection as CellSelection; 79 | ist(sel.$anchorCell.pos, 32); 80 | ist(sel.$headCell.pos, 38); 81 | }); 82 | }); 83 | 84 | describe('CellSelection.content', () => { 85 | function slice(doc: Node) { 86 | return new Slice(doc.content, 1, 1); 87 | } 88 | 89 | it('contains only the selected cells', () => 90 | ist( 91 | selectionFor( 92 | table( 93 | tr(c11, cAnchor, cEmpty), 94 | tr(c11, cEmpty, cHead), 95 | tr(c11, c11, c11), 96 | ), 97 | ).content(), 98 | slice(table('', tr(c11, cEmpty), tr(cEmpty, c11))), 99 | eq, 100 | )); 101 | 102 | it('understands spanning cells', () => 103 | ist( 104 | selectionFor( 105 | table(tr(cAnchor, c(2, 2), c11, c11), tr(c11, cHead, c11, c11)), 106 | ).content(), 107 | slice(table(tr(c11, c(2, 2), c11), tr(c11, c11))), 108 | eq, 109 | )); 110 | 111 | it('cuts off cells sticking out horizontally', () => 112 | ist( 113 | selectionFor( 114 | table(tr(c11, cAnchor, c(2, 1)), tr(c(4, 1)), tr(c(2, 1), cHead, c11)), 115 | ).content(), 116 | slice(table(tr(c11, c11), tr(td({ colspan: 2 }, p())), tr(cEmpty, c11))), 117 | eq, 118 | )); 119 | 120 | it('cuts off cells sticking out vertically', () => 121 | ist( 122 | selectionFor( 123 | table( 124 | tr(c11, c(1, 4), c(1, 2)), 125 | tr(cAnchor), 126 | tr(c(1, 2), cHead), 127 | tr(c11), 128 | ), 129 | ).content(), 130 | slice(table(tr(c11, td({ rowspan: 2 }, p()), cEmpty), tr(c11, c11))), 131 | eq, 132 | )); 133 | 134 | it('preserves column widths', () => 135 | ist( 136 | selectionFor( 137 | table( 138 | tr(c11, cAnchor, c11), 139 | tr(td({ colspan: 3, colwidth: [100, 200, 300] }, p('x'))), 140 | tr(c11, cHead, c11), 141 | ), 142 | ).content(), 143 | slice(table(tr(c11), tr(td({ colwidth: [200] }, p())), tr(c11))), 144 | eq, 145 | )); 146 | }); 147 | 148 | describe('normalizeSelection', () => { 149 | const t = doc( 150 | table( 151 | tr(/* 2*/ c11, /* 7*/ c11, /*12*/ c11), 152 | tr(/*19*/ c11, /*24*/ c11, /*29*/ c11), 153 | tr(/*36*/ c11, /*41*/ c11, /*46*/ c11), 154 | ), 155 | ); 156 | 157 | function normalize( 158 | selection: Selection, 159 | { allowTableNodeSelection = false } = {}, 160 | ) { 161 | const state = EditorState.create({ 162 | doc: t, 163 | selection, 164 | plugins: [tableEditing({ allowTableNodeSelection })], 165 | }); 166 | return state.apply(state.tr).selection; 167 | } 168 | 169 | it('converts a table node selection into a selection of all cells in the table', () => { 170 | ist( 171 | normalize(NodeSelection.create(t, 0)), 172 | CellSelection.create(t, 2, 46), 173 | eq, 174 | ); 175 | }); 176 | 177 | it('retains a table node selection if the allowTableNodeSelection option is true', () => { 178 | ist( 179 | normalize(NodeSelection.create(t, 0), { allowTableNodeSelection: true }), 180 | NodeSelection.create(t, 0), 181 | eq, 182 | ); 183 | }); 184 | 185 | it('converts a row node selection into a cell selection', () => { 186 | ist( 187 | normalize(NodeSelection.create(t, 1)), 188 | CellSelection.create(t, 2, 12), 189 | eq, 190 | ); 191 | }); 192 | 193 | it('converts a cell node selection into a cell selection', () => { 194 | ist( 195 | normalize(NodeSelection.create(t, 2)), 196 | CellSelection.create(t, 2, 2), 197 | eq, 198 | ); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.8.5](https://github.com/ProseMirror/prosemirror-tables/compare/v1.8.4...v1.8.5) (2025-12-24) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * improve cell selection with fallback strategy for merged cells ([#323](https://github.com/ProseMirror/prosemirror-tables/issues/323)) ([d141168](https://github.com/ProseMirror/prosemirror-tables/commit/d1411689ef3cf5871e80ce641e916755b4c29e08)) 9 | 10 | ## [1.8.4](https://github.com/ProseMirror/prosemirror-tables/compare/v1.8.3...v1.8.4) (2025-12-22) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * prevent cell selection on context menu open ([#324](https://github.com/ProseMirror/prosemirror-tables/issues/324)) ([92cd56b](https://github.com/ProseMirror/prosemirror-tables/commit/92cd56bdc5353a5e15625fa586140301a64546e8)) 16 | 17 | ## [1.8.3](https://github.com/ProseMirror/prosemirror-tables/compare/v1.8.2...v1.8.3) (2025-12-03) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * update repository URL in package.json ([#320](https://github.com/ProseMirror/prosemirror-tables/issues/320)) ([cdd85e6](https://github.com/ProseMirror/prosemirror-tables/commit/cdd85e6b23dbf1dc2c6de53ed986bf5163b486c0)) 23 | 24 | ## [1.8.2](https://github.com/ProseMirror/prosemirror-tables/compare/v1.8.1...v1.8.2) (2025-12-03) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * improve cell selection logic in merge cells ([#311](https://github.com/ProseMirror/prosemirror-tables/issues/311)) ([6ac5448](https://github.com/ProseMirror/prosemirror-tables/commit/6ac54486189a51ecefa3b43d1e29c6c4069552cf)) 30 | 31 | ## [1.8.1](https://github.com/ProseMirror/prosemirror-tables/compare/v1.8.0...v1.8.1) (2025-08-27) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * keep table cell type when moving a row ([#301](https://github.com/ProseMirror/prosemirror-tables/issues/301)) ([98cdf2d](https://github.com/ProseMirror/prosemirror-tables/commit/98cdf2d07e99acbd0e6aecfcc6f8acba2f0e7e65)) 37 | 38 | ## [1.8.0](https://github.com/ProseMirror/prosemirror-tables/compare/v1.7.1...v1.8.0) (2025-08-27) 39 | 40 | 41 | ### Features 42 | 43 | * add more commands and utils ([#296](https://github.com/ProseMirror/prosemirror-tables/issues/296)) ([bf4fc63](https://github.com/ProseMirror/prosemirror-tables/commit/bf4fc6332425f1d1689c29ecc4b70d722053dec8)) 44 | 45 | New commands: 46 | 47 | - `moveTableRow` 48 | - `moveTableColumn` 49 | 50 | New utils: 51 | 52 | - `findTable` 53 | - `findCellRange` 54 | - `findCellPos` 55 | 56 | ## [1.7.1](https://github.com/ProseMirror/prosemirror-tables/compare/v1.7.0...v1.7.1) (2025-04-17) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * fix validate for attribute colwidth ([#286](https://github.com/ProseMirror/prosemirror-tables/issues/286)) ([3346c6c](https://github.com/ProseMirror/prosemirror-tables/commit/3346c6c798f462a4f3d1c5ab47a2f74d62a07921)) 62 | 63 | ## [1.7.0](https://github.com/ProseMirror/prosemirror-tables/compare/v1.6.4...v1.7.0) (2025-04-14) 64 | 65 | 66 | ### Features 67 | 68 | * add validate support for schema ([#279](https://github.com/ProseMirror/prosemirror-tables/issues/279)) ([e74456a](https://github.com/ProseMirror/prosemirror-tables/commit/e74456a58caf381b375920f1cd752ef063acb98a)) 69 | 70 | ## [1.6.4](https://github.com/ProseMirror/prosemirror-tables/compare/v1.6.3...v1.6.4) (2025-02-06) 71 | 72 | 73 | ### Bug Fixes 74 | 75 | * remove zero sized tables via `fixTables` ([#267](https://github.com/ProseMirror/prosemirror-tables/issues/267)) ([fd6be97](https://github.com/ProseMirror/prosemirror-tables/commit/fd6be971b799b5c6d2c1a30a52032831e5fedddc)) 76 | 77 | ## [1.6.3](https://github.com/ProseMirror/prosemirror-tables/compare/v1.6.2...v1.6.3) (2025-01-31) 78 | 79 | 80 | ### Bug Fixes 81 | 82 | * disable resizing when the editor view is uneditable ([#261](https://github.com/ProseMirror/prosemirror-tables/issues/261)) ([8e7287c](https://github.com/ProseMirror/prosemirror-tables/commit/8e7287cfa47bab0da9a9e38cd9f65c7ece95d67d)) 83 | 84 | ## [1.6.2](https://github.com/ProseMirror/prosemirror-tables/compare/v1.6.1...v1.6.2) (2024-12-24) 85 | 86 | 87 | ### Bug Fixes 88 | 89 | * update prosemirror dependencies and fix type of ignoreMutation ([#259](https://github.com/ProseMirror/prosemirror-tables/issues/259)) ([465754b](https://github.com/ProseMirror/prosemirror-tables/commit/465754b97ecbca4778e0cc667511cd59f16db92a)) 90 | 91 | ## [1.6.1](https://github.com/ProseMirror/prosemirror-tables/compare/v1.6.0...v1.6.1) (2024-10-30) 92 | 93 | 94 | ### Bug Fixes 95 | 96 | * support defaultCellMinWidth in older Safari ([#255](https://github.com/ProseMirror/prosemirror-tables/issues/255)) ([1b36002](https://github.com/ProseMirror/prosemirror-tables/commit/1b36002196b6bdad11fce40b5a03e15a934f03e6)) 97 | 98 | ## [1.6.0](https://github.com/ProseMirror/prosemirror-tables/compare/v1.5.1...v1.6.0) (2024-10-25) 99 | 100 | 101 | ### Features 102 | 103 | * add new option `defaultCellMinWidth` for `columnResizing()` ([#253](https://github.com/ProseMirror/prosemirror-tables/issues/253)) ([662e857](https://github.com/ProseMirror/prosemirror-tables/commit/662e857d87fafcb5f77247205c2e91d392b7401d)) 104 | 105 | ## [1.5.1](https://github.com/ProseMirror/prosemirror-tables/compare/v1.5.0...v1.5.1) (2024-10-23) 106 | 107 | 108 | ### Bug Fixes 109 | 110 | * fix cell boundary selection cheap elimination ([#251](https://github.com/ProseMirror/prosemirror-tables/issues/251)) ([41e4139](https://github.com/ProseMirror/prosemirror-tables/commit/41e4139073f2e97bc86987adf80c7f3fa5a6dbda)) 111 | 112 | ## [1.5.0](https://github.com/ProseMirror/prosemirror-tables/compare/v1.4.2...v1.5.0) (2024-08-22) 113 | 114 | 115 | ### Features 116 | 117 | * export `cellNear` helper and `deleteCellSelection` command ([#239](https://github.com/ProseMirror/prosemirror-tables/issues/239)) ([fb7345b](https://github.com/ProseMirror/prosemirror-tables/commit/fb7345b2f39a8f022e3be32e4022d8697e683d6c)) 118 | 119 | ## [1.4.2](https://github.com/ProseMirror/prosemirror-tables/compare/v1.4.1...v1.4.2) (2024-08-22) 120 | 121 | 122 | ### Miscellaneous Chores 123 | 124 | * trigger release ([0ea1951](https://github.com/ProseMirror/prosemirror-tables/commit/0ea1951a22fc0e70713a26ce87e2875cae6b5887)) 125 | 126 | ## [1.4.1](https://github.com/ProseMirror/prosemirror-tables/compare/v1.4.0...v1.4.1) (2024-08-22) 127 | 128 | 129 | ### Continuous Integration 130 | 131 | * add release workflow ([#241](https://github.com/ProseMirror/prosemirror-tables/issues/241)) ([469cb11](https://github.com/ProseMirror/prosemirror-tables/commit/469cb11d2e3aa9e1b5b3e2a540431da69f1d64a1)) 132 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | // Helper for creating a schema that supports tables. 2 | 3 | import type { 4 | AttributeSpec, 5 | Attrs, 6 | Node, 7 | NodeSpec, 8 | NodeType, 9 | Schema, 10 | } from 'prosemirror-model'; 11 | 12 | import type { CellAttrs, MutableAttrs } from './util'; 13 | 14 | function getCellAttrs(dom: HTMLElement | string, extraAttrs: Attrs): Attrs { 15 | if (typeof dom === 'string') { 16 | return {}; 17 | } 18 | 19 | const widthAttr = dom.getAttribute('data-colwidth'); 20 | const widths = 21 | widthAttr && /^\d+(,\d+)*$/.test(widthAttr) 22 | ? widthAttr.split(',').map((s) => Number(s)) 23 | : null; 24 | const colspan = Number(dom.getAttribute('colspan') || 1); 25 | const result: MutableAttrs = { 26 | colspan, 27 | rowspan: Number(dom.getAttribute('rowspan') || 1), 28 | colwidth: widths && widths.length == colspan ? widths : null, 29 | } satisfies CellAttrs; 30 | for (const prop in extraAttrs) { 31 | const getter = extraAttrs[prop].getFromDOM; 32 | const value = getter && getter(dom); 33 | if (value != null) { 34 | result[prop] = value; 35 | } 36 | } 37 | return result; 38 | } 39 | 40 | function setCellAttrs(node: Node, extraAttrs: Attrs): Attrs { 41 | const attrs: MutableAttrs = {}; 42 | if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan; 43 | if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan; 44 | if (node.attrs.colwidth) 45 | attrs['data-colwidth'] = node.attrs.colwidth.join(','); 46 | for (const prop in extraAttrs) { 47 | const setter = extraAttrs[prop].setDOMAttr; 48 | if (setter) setter(node.attrs[prop], attrs); 49 | } 50 | return attrs; 51 | } 52 | 53 | /** 54 | * @public 55 | */ 56 | export type getFromDOM = (dom: HTMLElement) => unknown; 57 | 58 | /** 59 | * @public 60 | */ 61 | export type setDOMAttr = (value: unknown, attrs: MutableAttrs) => void; 62 | 63 | /** 64 | * @public 65 | */ 66 | export interface CellAttributes { 67 | /** 68 | * The attribute's default value. 69 | */ 70 | default: unknown; 71 | 72 | /** 73 | * A function or type name used to validate values of this attribute. 74 | * 75 | * See [validate](https://prosemirror.net/docs/ref/#model.AttributeSpec.validate). 76 | */ 77 | validate?: string | ((value: unknown) => void); 78 | 79 | /** 80 | * A function to read the attribute's value from a DOM node. 81 | */ 82 | getFromDOM?: getFromDOM; 83 | 84 | /** 85 | * A function to add the attribute's value to an attribute 86 | * object that's used to render the cell's DOM. 87 | */ 88 | setDOMAttr?: setDOMAttr; 89 | } 90 | 91 | /** 92 | * @public 93 | */ 94 | export interface TableNodesOptions { 95 | /** 96 | * A group name (something like `"block"`) to add to the table 97 | * node type. 98 | */ 99 | tableGroup?: string; 100 | 101 | /** 102 | * The content expression for table cells. 103 | */ 104 | cellContent: string; 105 | 106 | /** 107 | * Additional attributes to add to cells. Maps attribute names to 108 | * objects with the following properties: 109 | */ 110 | cellAttributes: { [key: string]: CellAttributes }; 111 | } 112 | 113 | /** 114 | * @public 115 | */ 116 | export type TableNodes = Record< 117 | 'table' | 'table_row' | 'table_cell' | 'table_header', 118 | NodeSpec 119 | >; 120 | 121 | function validateColwidth(value: unknown) { 122 | if (value === null) { 123 | return; 124 | } 125 | if (!Array.isArray(value)) { 126 | throw new TypeError('colwidth must be null or an array'); 127 | } 128 | for (const item of value) { 129 | if (typeof item !== 'number') { 130 | throw new TypeError('colwidth must be null or an array of numbers'); 131 | } 132 | } 133 | } 134 | 135 | /** 136 | * This function creates a set of [node 137 | * specs](http://prosemirror.net/docs/ref/#model.SchemaSpec.nodes) for 138 | * `table`, `table_row`, and `table_cell` nodes types as used by this 139 | * module. The result can then be added to the set of nodes when 140 | * creating a schema. 141 | * 142 | * @public 143 | */ 144 | export function tableNodes(options: TableNodesOptions): TableNodes { 145 | const extraAttrs = options.cellAttributes || {}; 146 | const cellAttrs: Record = { 147 | colspan: { default: 1, validate: 'number' }, 148 | rowspan: { default: 1, validate: 'number' }, 149 | colwidth: { default: null, validate: validateColwidth }, 150 | }; 151 | for (const prop in extraAttrs) 152 | cellAttrs[prop] = { 153 | default: extraAttrs[prop].default, 154 | validate: extraAttrs[prop].validate, 155 | }; 156 | 157 | return { 158 | table: { 159 | content: 'table_row+', 160 | tableRole: 'table', 161 | isolating: true, 162 | group: options.tableGroup, 163 | parseDOM: [{ tag: 'table' }], 164 | toDOM() { 165 | return ['table', ['tbody', 0]]; 166 | }, 167 | }, 168 | table_row: { 169 | content: '(table_cell | table_header)*', 170 | tableRole: 'row', 171 | parseDOM: [{ tag: 'tr' }], 172 | toDOM() { 173 | return ['tr', 0]; 174 | }, 175 | }, 176 | table_cell: { 177 | content: options.cellContent, 178 | attrs: cellAttrs, 179 | tableRole: 'cell', 180 | isolating: true, 181 | parseDOM: [ 182 | { tag: 'td', getAttrs: (dom) => getCellAttrs(dom, extraAttrs) }, 183 | ], 184 | toDOM(node) { 185 | return ['td', setCellAttrs(node, extraAttrs), 0]; 186 | }, 187 | }, 188 | table_header: { 189 | content: options.cellContent, 190 | attrs: cellAttrs, 191 | tableRole: 'header_cell', 192 | isolating: true, 193 | parseDOM: [ 194 | { tag: 'th', getAttrs: (dom) => getCellAttrs(dom, extraAttrs) }, 195 | ], 196 | toDOM(node) { 197 | return ['th', setCellAttrs(node, extraAttrs), 0]; 198 | }, 199 | }, 200 | }; 201 | } 202 | 203 | /** 204 | * @public 205 | */ 206 | export type TableRole = 'table' | 'row' | 'cell' | 'header_cell'; 207 | 208 | /** 209 | * @public 210 | */ 211 | export function tableNodeTypes(schema: Schema): Record { 212 | let result = schema.cached.tableNodeTypes; 213 | if (!result) { 214 | result = schema.cached.tableNodeTypes = {}; 215 | for (const name in schema.nodes) { 216 | const type = schema.nodes[name], 217 | role = type.spec.tableRole; 218 | if (role) result[role] = type; 219 | } 220 | } 221 | return result; 222 | } 223 | -------------------------------------------------------------------------------- /src/utils/selection-range.ts: -------------------------------------------------------------------------------- 1 | import type { ResolvedPos } from 'prosemirror-model'; 2 | import type { Transaction } from 'prosemirror-state'; 3 | 4 | import { getCellsInColumn, getCellsInRow } from './get-cells'; 5 | 6 | export type CellSelectionRange = { 7 | $anchor: ResolvedPos; 8 | $head: ResolvedPos; 9 | // an array of column/row indexes 10 | indexes: number[]; 11 | }; 12 | 13 | /** 14 | * Returns a range of rectangular selection spanning all merged cells around a 15 | * column at index `columnIndex`. 16 | * 17 | * Original implementation from Atlassian (Apache License 2.0) 18 | * 19 | * https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-column.ts#editor/editor-tables/src/utils/get-selection-range-in-column.ts 20 | * 21 | * @internal 22 | */ 23 | export function getSelectionRangeInColumn( 24 | tr: Transaction, 25 | startColIndex: number, 26 | endColIndex: number = startColIndex, 27 | ): CellSelectionRange | undefined { 28 | let startIndex = startColIndex; 29 | let endIndex = endColIndex; 30 | 31 | // looking for selection start column (startIndex) 32 | for (let i = startColIndex; i >= 0; i--) { 33 | const cells = getCellsInColumn(i, tr.selection); 34 | if (cells) { 35 | cells.forEach((cell) => { 36 | const maybeEndIndex = cell.node.attrs.colspan + i - 1; 37 | if (maybeEndIndex >= startIndex) { 38 | startIndex = i; 39 | } 40 | if (maybeEndIndex > endIndex) { 41 | endIndex = maybeEndIndex; 42 | } 43 | }); 44 | } 45 | } 46 | // looking for selection end column (endIndex) 47 | for (let i = startColIndex; i <= endIndex; i++) { 48 | const cells = getCellsInColumn(i, tr.selection); 49 | if (cells) { 50 | cells.forEach((cell) => { 51 | const maybeEndIndex = cell.node.attrs.colspan + i - 1; 52 | if (cell.node.attrs.colspan > 1 && maybeEndIndex > endIndex) { 53 | endIndex = maybeEndIndex; 54 | } 55 | }); 56 | } 57 | } 58 | 59 | // filter out columns without cells (where all rows have colspan > 1 in the same column) 60 | const indexes = []; 61 | for (let i = startIndex; i <= endIndex; i++) { 62 | const maybeCells = getCellsInColumn(i, tr.selection); 63 | if (maybeCells && maybeCells.length > 0) { 64 | indexes.push(i); 65 | } 66 | } 67 | startIndex = indexes[0]; 68 | endIndex = indexes[indexes.length - 1]; 69 | 70 | const firstSelectedColumnCells = getCellsInColumn(startIndex, tr.selection); 71 | const firstRowCells = getCellsInRow(0, tr.selection); 72 | if (!firstSelectedColumnCells || !firstRowCells) { 73 | return; 74 | } 75 | 76 | const $anchor = tr.doc.resolve( 77 | firstSelectedColumnCells[firstSelectedColumnCells.length - 1].pos, 78 | ); 79 | 80 | let headCell; 81 | for (let i = endIndex; i >= startIndex; i--) { 82 | const columnCells = getCellsInColumn(i, tr.selection); 83 | if (columnCells && columnCells.length > 0) { 84 | for (let j = firstRowCells.length - 1; j >= 0; j--) { 85 | if (firstRowCells[j].pos === columnCells[0].pos) { 86 | headCell = columnCells[0]; 87 | break; 88 | } 89 | } 90 | if (headCell) { 91 | break; 92 | } 93 | } 94 | } 95 | if (!headCell) { 96 | return; 97 | } 98 | 99 | const $head = tr.doc.resolve(headCell.pos); 100 | return { $anchor, $head, indexes }; 101 | } 102 | 103 | /** 104 | * Returns a range of rectangular selection spanning all merged cells around a 105 | * row at index `rowIndex`. 106 | * 107 | * Original implementation from Atlassian (Apache License 2.0) 108 | * 109 | * https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/5f91cb871e8248bc3bae5ddc30bb9fd9200fadbb/editor/editor-tables/src/utils/get-selection-range-in-row.ts#editor/editor-tables/src/utils/get-selection-range-in-row.ts 110 | * 111 | * @internal 112 | */ 113 | export function getSelectionRangeInRow( 114 | tr: Transaction, 115 | startRowIndex: number, 116 | endRowIndex: number = startRowIndex, 117 | ): CellSelectionRange | undefined { 118 | let startIndex = startRowIndex; 119 | let endIndex = endRowIndex; 120 | 121 | // looking for selection start row (startIndex) 122 | for (let i = startRowIndex; i >= 0; i--) { 123 | const cells = getCellsInRow(i, tr.selection); 124 | if (cells) { 125 | cells.forEach((cell) => { 126 | const maybeEndIndex = cell.node.attrs.rowspan + i - 1; 127 | if (maybeEndIndex >= startIndex) { 128 | startIndex = i; 129 | } 130 | if (maybeEndIndex > endIndex) { 131 | endIndex = maybeEndIndex; 132 | } 133 | }); 134 | } 135 | } 136 | // looking for selection end row (endIndex) 137 | for (let i = startRowIndex; i <= endIndex; i++) { 138 | const cells = getCellsInRow(i, tr.selection); 139 | if (cells) { 140 | cells.forEach((cell) => { 141 | const maybeEndIndex = cell.node.attrs.rowspan + i - 1; 142 | if (cell.node.attrs.rowspan > 1 && maybeEndIndex > endIndex) { 143 | endIndex = maybeEndIndex; 144 | } 145 | }); 146 | } 147 | } 148 | 149 | // filter out rows without cells (where all columns have rowspan > 1 in the same row) 150 | const indexes = []; 151 | for (let i = startIndex; i <= endIndex; i++) { 152 | const maybeCells = getCellsInRow(i, tr.selection); 153 | if (maybeCells && maybeCells.length > 0) { 154 | indexes.push(i); 155 | } 156 | } 157 | startIndex = indexes[0]; 158 | endIndex = indexes[indexes.length - 1]; 159 | 160 | const firstSelectedRowCells = getCellsInRow(startIndex, tr.selection); 161 | const firstColumnCells = getCellsInColumn(0, tr.selection); 162 | if (!firstSelectedRowCells || !firstColumnCells) { 163 | return; 164 | } 165 | 166 | const $anchor = tr.doc.resolve( 167 | firstSelectedRowCells[firstSelectedRowCells.length - 1].pos, 168 | ); 169 | 170 | let headCell; 171 | for (let i = endIndex; i >= startIndex; i--) { 172 | const rowCells = getCellsInRow(i, tr.selection); 173 | if (rowCells && rowCells.length > 0) { 174 | for (let j = firstColumnCells.length - 1; j >= 0; j--) { 175 | if (firstColumnCells[j].pos === rowCells[0].pos) { 176 | headCell = rowCells[0]; 177 | break; 178 | } 179 | } 180 | if (headCell) { 181 | break; 182 | } 183 | } 184 | } 185 | if (!headCell) { 186 | return; 187 | } 188 | 189 | const $head = tr.doc.resolve(headCell.pos); 190 | return { $anchor, $head, indexes }; 191 | } 192 | -------------------------------------------------------------------------------- /test/convert-array-of-rows-to-table-node.test.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'prosemirror-model'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | import { 5 | convertArrayOfRowsToTableNode, 6 | convertTableNodeToArrayOfRows, 7 | } from '../src/utils/convert'; 8 | 9 | import { c, p, table, td, tr } from './build'; 10 | 11 | describe('convertArrayOfRowsToTableNode', () => { 12 | const expectTableEquals = (a: Node, b: Node) => { 13 | // a and b are not the same node 14 | expect(a !== b).toBe(true); 15 | 16 | // a and b have the same data 17 | expect(a.eq(b)).toBe(true); 18 | }; 19 | 20 | it('should convert array of rows back to table node (roundtrip)', () => { 21 | const originalTable = table( 22 | tr(c(1, 1, 'A1'), c(1, 1, 'B1')), 23 | tr(c(1, 1, 'A2'), c(1, 1, 'B2')), 24 | ); 25 | 26 | const arrayOfRows = convertTableNodeToArrayOfRows(originalTable); 27 | const newTable = convertArrayOfRowsToTableNode(originalTable, arrayOfRows); 28 | 29 | expectTableEquals(originalTable, newTable); 30 | }); 31 | 32 | it('should handle modified cell content', () => { 33 | const originalTable = table( 34 | tr(c(1, 1, 'A1'), c(1, 1, 'B1')), 35 | tr(c(1, 1, 'A2'), c(1, 1, 'B2')), 36 | ); 37 | 38 | const arrayOfRows = convertTableNodeToArrayOfRows(originalTable); 39 | // Modify the content of one cell 40 | arrayOfRows[0][1] = td(p('Modified')); 41 | 42 | const newTable = convertArrayOfRowsToTableNode(originalTable, arrayOfRows); 43 | 44 | const expectedTable = table( 45 | tr(c(1, 1, 'A1'), c(1, 1, 'Modified')), 46 | tr(c(1, 1, 'A2'), c(1, 1, 'B2')), 47 | ); 48 | 49 | expectTableEquals(expectedTable, newTable); 50 | }); 51 | 52 | it('should handle empty cells in array', () => { 53 | const originalTable = table( 54 | tr(c(1, 1, 'A1'), c(1, 1, 'B1')), 55 | tr(c(1, 1, 'A2'), c(1, 1, 'B2')), 56 | ); 57 | 58 | const arrayOfRows = convertTableNodeToArrayOfRows(originalTable); 59 | // Replace one cell with an empty cell 60 | arrayOfRows[1][0] = td(p()); 61 | 62 | const newTable = convertArrayOfRowsToTableNode(originalTable, arrayOfRows); 63 | 64 | const expectedTable = table( 65 | tr(c(1, 1, 'A1'), c(1, 1, 'B1')), 66 | tr(c(1, 1, ''), c(1, 1, 'B2')), 67 | ); 68 | 69 | expectTableEquals(expectedTable, newTable); 70 | }); 71 | 72 | it('should handle multiple cell modifications', () => { 73 | const originalTable = table( 74 | tr(c(1, 1, 'A1'), c(1, 1, 'B1'), c(1, 1, 'C1')), 75 | tr(c(1, 1, 'A2'), c(1, 1, 'B2'), c(1, 1, 'C2')), 76 | tr(c(1, 1, 'A3'), c(1, 1, 'B3'), c(1, 1, 'C3')), 77 | ); 78 | 79 | const arrayOfRows = convertTableNodeToArrayOfRows(originalTable); 80 | // Modify multiple cells 81 | arrayOfRows[0][0] = td(p('New A1')); 82 | arrayOfRows[1][1] = td(p('New B2')); 83 | arrayOfRows[2][2] = td(p('New C3')); 84 | 85 | const newTable = convertArrayOfRowsToTableNode(originalTable, arrayOfRows); 86 | 87 | const expectedTable = table( 88 | tr(c(1, 1, 'New A1'), c(1, 1, 'B1'), c(1, 1, 'C1')), 89 | tr(c(1, 1, 'A2'), c(1, 1, 'New B2'), c(1, 1, 'C2')), 90 | tr(c(1, 1, 'A3'), c(1, 1, 'B3'), c(1, 1, 'New C3')), 91 | ); 92 | 93 | expectTableEquals(expectedTable, newTable); 94 | }); 95 | 96 | it('should handle tables with merged cells', () => { 97 | const originalTable = table( 98 | tr(c(1, 1, 'A1'), c(1, 1, 'B1'), c(2, 1, 'C1')), 99 | tr(c(1, 1, 'A2'), c(2, 1, 'B2'), c(1, 2, 'D1')), 100 | tr(c(1, 1, 'A3'), c(1, 1, 'B3'), c(1, 1, 'C3')), 101 | ); 102 | 103 | const arrayOfRows = convertTableNodeToArrayOfRows(originalTable); 104 | const newTable = convertArrayOfRowsToTableNode(originalTable, arrayOfRows); 105 | 106 | expectTableEquals(originalTable, newTable); 107 | }); 108 | 109 | it('should handle modified cells in merged table', () => { 110 | const originalTable = table( 111 | tr(c(1, 1, 'A1'), c(1, 1, 'B1'), c(2, 1, 'C1')), 112 | tr(c(1, 1, 'A2'), c(2, 1, 'B2'), c(1, 2, 'D1')), 113 | tr(c(1, 1, 'A3'), c(1, 1, 'B3'), c(1, 1, 'C3')), 114 | ); 115 | 116 | const arrayOfRows = convertTableNodeToArrayOfRows(originalTable); 117 | // Modify a cell in the merged table 118 | arrayOfRows[0][2] = td( 119 | { colspan: 2, rowspan: 1, colwidth: null }, 120 | p('Modified C1'), 121 | ); 122 | 123 | const newTable = convertArrayOfRowsToTableNode(originalTable, arrayOfRows); 124 | 125 | const expectedTable = table( 126 | tr(c(1, 1, 'A1'), c(1, 1, 'B1'), c(2, 1, 'Modified C1')), 127 | tr(c(1, 1, 'A2'), c(2, 1, 'B2'), c(1, 2, 'D1')), 128 | tr(c(1, 1, 'A3'), c(1, 1, 'B3'), c(1, 1, 'C3')), 129 | ); 130 | 131 | expectTableEquals(expectedTable, newTable); 132 | }); 133 | 134 | it('should handle single row table conversion', () => { 135 | const originalTable = table( 136 | tr(c(1, 1, 'Single'), c(1, 1, 'Row'), c(1, 1, 'Table')), 137 | ); 138 | 139 | const arrayOfRows = convertTableNodeToArrayOfRows(originalTable); 140 | // Modify middle cell 141 | arrayOfRows[0][1] = td(p('Modified')); 142 | 143 | const newTable = convertArrayOfRowsToTableNode(originalTable, arrayOfRows); 144 | 145 | const expectedTable = table( 146 | tr(c(1, 1, 'Single'), c(1, 1, 'Modified'), c(1, 1, 'Table')), 147 | ); 148 | 149 | expectTableEquals(expectedTable, newTable); 150 | }); 151 | 152 | it('should handle single column table conversion', () => { 153 | const originalTable = table( 154 | tr(c(1, 1, 'A1')), 155 | tr(c(1, 1, 'A2')), 156 | tr(c(1, 1, 'A3')), 157 | ); 158 | 159 | const arrayOfRows = convertTableNodeToArrayOfRows(originalTable); 160 | // Modify middle cell 161 | arrayOfRows[1][0] = td(p('Modified A2')); 162 | 163 | const newTable = convertArrayOfRowsToTableNode(originalTable, arrayOfRows); 164 | 165 | const expectedTable = table( 166 | tr(c(1, 1, 'A1')), 167 | tr(c(1, 1, 'Modified A2')), 168 | tr(c(1, 1, 'A3')), 169 | ); 170 | 171 | expectTableEquals(expectedTable, newTable); 172 | }); 173 | 174 | it('should preserve cell attributes when modifying content', () => { 175 | const originalTable = table( 176 | tr(c(1, 1, 'A1'), c(2, 1, 'B1')), 177 | tr(c(1, 2, 'A2'), c(1, 1, 'B2'), c(1, 1, 'C2')), 178 | tr(c(1, 1, 'B3'), c(1, 1, 'C3')), 179 | ); 180 | 181 | const arrayOfRows = convertTableNodeToArrayOfRows(originalTable); 182 | // Modify content while preserving attributes 183 | arrayOfRows[0][1] = td( 184 | { colspan: 2, rowspan: 1, colwidth: null }, 185 | p('Modified B1'), 186 | ); 187 | arrayOfRows[1][0] = td( 188 | { colspan: 1, rowspan: 2, colwidth: null }, 189 | p('Modified A2'), 190 | ); 191 | 192 | const newTable = convertArrayOfRowsToTableNode(originalTable, arrayOfRows); 193 | 194 | const expectedTable = table( 195 | tr(c(1, 1, 'A1'), c(2, 1, 'Modified B1')), 196 | tr(c(1, 2, 'Modified A2'), c(1, 1, 'B2'), c(1, 1, 'C2')), 197 | tr(c(1, 1, 'B3'), c(1, 1, 'C3')), 198 | ); 199 | 200 | expectTableEquals(expectedTable, newTable); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/copypaste.test.ts: -------------------------------------------------------------------------------- 1 | import ist from 'ist'; 2 | import type { Node } from 'prosemirror-model'; 3 | import { Fragment } from 'prosemirror-model'; 4 | import { EditorState } from 'prosemirror-state'; 5 | import { describe, it } from 'vitest'; 6 | 7 | import { 8 | cellAround, 9 | TableMap, 10 | __clipCells as clipCells, 11 | __insertCells as insertCells, 12 | __pastedCells as pastedCells, 13 | } from '../src'; 14 | 15 | import type { TaggedNode } from './build'; 16 | import { 17 | c, 18 | c11, 19 | cAnchor, 20 | cEmpty, 21 | doc, 22 | eq, 23 | h11, 24 | hEmpty, 25 | p, 26 | table, 27 | td, 28 | tr, 29 | } from './build'; 30 | 31 | describe('pastedCells', () => { 32 | function test( 33 | slice: TaggedNode, 34 | width: number | null, 35 | height: number | null, 36 | content?: Node[][] | null, 37 | ) { 38 | const result = pastedCells(slice.slice(slice.tag.a, slice.tag.b)); 39 | if (width == null) { 40 | return ist(result, null); 41 | } 42 | if (!result) { 43 | throw new Error("Can't paste cells"); 44 | } 45 | ist(result.rows.length, result.height); 46 | ist(result.width, width); 47 | ist(result.height, height); 48 | if (content) { 49 | result.rows.forEach((row, i) => ist(row, Fragment.from(content[i]), eq)); 50 | } 51 | } 52 | 53 | it('returns simple cells', () => 54 | test(doc(table(tr('', cEmpty, cEmpty, ''))), 2, 1, [ 55 | [cEmpty, cEmpty], 56 | ])); 57 | 58 | it('returns cells wrapped in a row', () => 59 | test(table('', tr(cEmpty, cEmpty), ''), 2, 1, [[cEmpty, cEmpty]])); 60 | 61 | it('returns cells when the cursor is inside them', () => 62 | test(table(tr(td(p('foo')), td(p('bar')))), 2, 1, [ 63 | [td(p('foo')), cEmpty], 64 | ])); 65 | 66 | it('returns multiple rows', () => 67 | test(table(tr('', cEmpty, cEmpty), tr(cEmpty, c11), ''), 2, 2, [ 68 | [cEmpty, cEmpty], 69 | [cEmpty, c11], 70 | ])); 71 | 72 | it('will enter a fully selected table', () => 73 | test(doc('', table(tr(c11)), ''), 1, 1, [[c11]])); 74 | 75 | it('can normalize a partially-selected row', () => 76 | test(table(tr(td(p(), ''), cEmpty, c11), tr(c11, c11), ''), 2, 2, [ 77 | [cEmpty, c11], 78 | [c11, c11], 79 | ])); 80 | 81 | it('will make sure the result is rectangular', () => 82 | test(table('', tr(c(2, 2), c11), tr(), tr(c11, c11), ''), 3, 3, [ 83 | [c(2, 2), c11], 84 | [cEmpty], 85 | [c11, c11, cEmpty], 86 | ])); 87 | 88 | it('can handle rowspans sticking out', () => 89 | test(table('', tr(c(1, 3), c11), ''), 2, 3, [ 90 | [c(1, 3), c11], 91 | [cEmpty], 92 | [cEmpty], 93 | ])); 94 | 95 | it('returns null for non-cell selection', () => 96 | test(doc(p('foobar'), p('baz')), null, null, null)); 97 | }); 98 | 99 | describe('clipCells', () => { 100 | function test( 101 | slice: TaggedNode, 102 | width: number, 103 | height: number, 104 | content?: Node[][], 105 | ) { 106 | const result = clipCells( 107 | pastedCells(slice.slice(slice.tag.a, slice.tag.b))!, 108 | width, 109 | height, 110 | ); 111 | ist(result.rows.length, result.height); 112 | ist(result.width, width); 113 | ist(result.height, height); 114 | if (content) 115 | result.rows.forEach((row, i) => ist(row, Fragment.from(content[i]), eq)); 116 | } 117 | 118 | it('can clip off excess cells', () => 119 | test(table('', tr(cEmpty, c11), tr(c11, c11), ''), 1, 1, [[cEmpty]])); 120 | 121 | it('will pad by repeating cells', () => 122 | test(table('', tr(cEmpty, c11), tr(c11, cEmpty), ''), 4, 4, [ 123 | [cEmpty, c11, cEmpty, c11], 124 | [c11, cEmpty, c11, cEmpty], 125 | [cEmpty, c11, cEmpty, c11], 126 | [c11, cEmpty, c11, cEmpty], 127 | ])); 128 | 129 | it('takes rowspan into account when counting width', () => 130 | test(table('', tr(c(2, 2), c11), tr(c11), ''), 6, 2, [ 131 | [c(2, 2), c11, c(2, 2), c11], 132 | [c11, c11], 133 | ])); 134 | 135 | it('clips off excess colspan', () => 136 | test(table('', tr(c(2, 2), c11), tr(c11), ''), 4, 2, [ 137 | [c(2, 2), c11, c(1, 2)], 138 | [c11], 139 | ])); 140 | 141 | it('clips off excess rowspan', () => 142 | test(table('', tr(c(2, 2), c11), tr(c11), ''), 2, 3, [ 143 | [c(2, 2)], 144 | [], 145 | [c(2, 1)], 146 | ])); 147 | 148 | it('clips off excess rowspan when new table height is bigger than the current table height', () => 149 | test(table('', tr(c(1, 2), c(2, 1)), tr(c11, c11), ''), 3, 1, [ 150 | [c(1, 1), c(2, 1)], 151 | ])); 152 | }); 153 | 154 | describe('insertCells', () => { 155 | function test(doc: TaggedNode, cells: TaggedNode, result: TaggedNode) { 156 | if ( 157 | doc.type.name !== 'doc' || 158 | cells.type.name !== 'doc' || 159 | result.type.name !== 'doc' 160 | ) { 161 | throw new Error('Invalid test'); 162 | } 163 | 164 | let state = EditorState.create({ doc }); 165 | const $cell = cellAround(doc.resolve(doc.tag.anchor)); 166 | if (!$cell) { 167 | throw new Error('No cell found'); 168 | } 169 | 170 | let table: Node | null = null; 171 | let tableStart = 0; 172 | doc.descendants((node, pos) => { 173 | if (node.type.name === 'table') { 174 | table = node; 175 | tableStart = pos + 1; 176 | } 177 | return !table; 178 | }); 179 | 180 | if (!table) { 181 | throw new Error(`No table found: ${doc.toString()}`); 182 | } 183 | 184 | const map = TableMap.get(table); 185 | insertCells( 186 | state, 187 | (tr) => (state = state.apply(tr)), 188 | tableStart, 189 | map.findCell($cell.pos - tableStart), 190 | pastedCells(cells.slice(cells.tag.a, cells.tag.b))!, 191 | ); 192 | ist(state.doc, result, eq); 193 | } 194 | 195 | it('keeps the original cells', () => 196 | test( 197 | doc(table(tr(cAnchor, c11, c11), tr(c11, c11, c11))), 198 | doc(table(tr(td(p('foo')), cEmpty), tr(c(2, 1), ''))), 199 | doc(table(tr(td(p('foo')), cEmpty, c11), tr(c(2, 1), c11))), 200 | )); 201 | 202 | it('makes sure the table is big enough', () => 203 | test( 204 | doc(table(tr(cAnchor))), 205 | doc(table(tr(td(p('foo')), cEmpty), tr(c(2, 1), ''))), 206 | doc(table(tr(td(p('foo')), cEmpty), tr(c(2, 1)))), 207 | )); 208 | 209 | it('preserves headers while growing a table', () => 210 | test( 211 | doc(table(tr(h11, h11, h11), tr(h11, c11, c11), tr(h11, c11, cAnchor))), 212 | doc(table(tr(td(p('foo')), cEmpty), tr(c11, c11, ''))), 213 | doc( 214 | table( 215 | tr(h11, h11, h11, hEmpty), 216 | tr(h11, c11, c11, cEmpty), 217 | tr(h11, c11, td(p('foo')), cEmpty), 218 | tr(hEmpty, cEmpty, c11, c11), 219 | ), 220 | ), 221 | )); 222 | 223 | it('will split interfering rowspan cells', () => 224 | test( 225 | doc( 226 | table( 227 | tr(c11, c(1, 4), c11), 228 | tr(cAnchor, c11), 229 | tr(c11, c11), 230 | tr(c11, c11), 231 | ), 232 | ), 233 | doc(table(tr('', cEmpty, cEmpty, cEmpty, ''))), 234 | doc( 235 | table( 236 | tr(c11, c11, c11), 237 | tr(cEmpty, cEmpty, cEmpty), 238 | tr(c11, td({ rowspan: 2 }, p()), c11), 239 | tr(c11, c11), 240 | ), 241 | ), 242 | )); 243 | 244 | it('will split interfering colspan cells', () => 245 | test( 246 | doc(table(tr(c11, cAnchor, c11), tr(c(2, 1), c11), tr(c11, c(2, 1)))), 247 | doc(table('', tr(cEmpty), tr(cEmpty), tr(cEmpty), '')), 248 | doc( 249 | table( 250 | tr(c11, cEmpty, c11), 251 | tr(c11, cEmpty, c11), 252 | tr(c11, cEmpty, cEmpty), 253 | ), 254 | ), 255 | )); 256 | 257 | it('preserves widths when splitting', () => 258 | test( 259 | doc( 260 | table( 261 | tr(c11, cAnchor, c11), 262 | tr(td({ colspan: 3, colwidth: [100, 200, 300] }, p('x'))), 263 | ), 264 | ), 265 | doc(table('', tr(cEmpty), tr(cEmpty), '')), 266 | doc( 267 | table( 268 | tr(c11, cEmpty, c11), 269 | tr( 270 | td({ colwidth: [100] }, p('x')), 271 | cEmpty, 272 | td({ colwidth: [300] }, p()), 273 | ), 274 | ), 275 | ), 276 | )); 277 | }); 278 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | // This file defines a number of helpers for wiring up user input to 2 | // table-related functionality. 3 | 4 | import { keydownHandler } from 'prosemirror-keymap'; 5 | import type { ResolvedPos, Slice } from 'prosemirror-model'; 6 | import { Fragment } from 'prosemirror-model'; 7 | import type { Command, EditorState, Transaction } from 'prosemirror-state'; 8 | import { Selection, TextSelection } from 'prosemirror-state'; 9 | import type { EditorView } from 'prosemirror-view'; 10 | 11 | import { CellSelection } from './cellselection'; 12 | import { deleteCellSelection } from './commands'; 13 | import { clipCells, fitSlice, insertCells, pastedCells } from './copypaste'; 14 | import { tableNodeTypes } from './schema'; 15 | import { TableMap } from './tablemap'; 16 | import { 17 | cellAround, 18 | inSameTable, 19 | isInTable, 20 | nextCell, 21 | selectionCell, 22 | tableEditingKey, 23 | } from './util'; 24 | 25 | type Axis = 'horiz' | 'vert'; 26 | 27 | /** 28 | * @public 29 | */ 30 | export type Direction = -1 | 1; 31 | 32 | export const handleKeyDown = keydownHandler({ 33 | ArrowLeft: arrow('horiz', -1), 34 | ArrowRight: arrow('horiz', 1), 35 | ArrowUp: arrow('vert', -1), 36 | ArrowDown: arrow('vert', 1), 37 | 38 | 'Shift-ArrowLeft': shiftArrow('horiz', -1), 39 | 'Shift-ArrowRight': shiftArrow('horiz', 1), 40 | 'Shift-ArrowUp': shiftArrow('vert', -1), 41 | 'Shift-ArrowDown': shiftArrow('vert', 1), 42 | 43 | Backspace: deleteCellSelection, 44 | 'Mod-Backspace': deleteCellSelection, 45 | Delete: deleteCellSelection, 46 | 'Mod-Delete': deleteCellSelection, 47 | }); 48 | 49 | function maybeSetSelection( 50 | state: EditorState, 51 | dispatch: undefined | ((tr: Transaction) => void), 52 | selection: Selection, 53 | ): boolean { 54 | if (selection.eq(state.selection)) return false; 55 | if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView()); 56 | return true; 57 | } 58 | 59 | /** 60 | * @internal 61 | */ 62 | export function arrow(axis: Axis, dir: Direction): Command { 63 | return (state, dispatch, view) => { 64 | if (!view) return false; 65 | const sel = state.selection; 66 | if (sel instanceof CellSelection) { 67 | return maybeSetSelection( 68 | state, 69 | dispatch, 70 | Selection.near(sel.$headCell, dir), 71 | ); 72 | } 73 | if (axis != 'horiz' && !sel.empty) return false; 74 | const end = atEndOfCell(view, axis, dir); 75 | if (end == null) return false; 76 | if (axis == 'horiz') { 77 | return maybeSetSelection( 78 | state, 79 | dispatch, 80 | Selection.near(state.doc.resolve(sel.head + dir), dir), 81 | ); 82 | } else { 83 | const $cell = state.doc.resolve(end); 84 | const $next = nextCell($cell, axis, dir); 85 | let newSel; 86 | if ($next) newSel = Selection.near($next, 1); 87 | else if (dir < 0) 88 | newSel = Selection.near(state.doc.resolve($cell.before(-1)), -1); 89 | else newSel = Selection.near(state.doc.resolve($cell.after(-1)), 1); 90 | return maybeSetSelection(state, dispatch, newSel); 91 | } 92 | }; 93 | } 94 | 95 | function shiftArrow(axis: Axis, dir: Direction): Command { 96 | return (state, dispatch, view) => { 97 | if (!view) return false; 98 | const sel = state.selection; 99 | let cellSel: CellSelection; 100 | if (sel instanceof CellSelection) { 101 | cellSel = sel; 102 | } else { 103 | const end = atEndOfCell(view, axis, dir); 104 | if (end == null) return false; 105 | cellSel = new CellSelection(state.doc.resolve(end)); 106 | } 107 | 108 | const $head = nextCell(cellSel.$headCell, axis, dir); 109 | if (!$head) return false; 110 | return maybeSetSelection( 111 | state, 112 | dispatch, 113 | new CellSelection(cellSel.$anchorCell, $head), 114 | ); 115 | }; 116 | } 117 | 118 | export function handleTripleClick(view: EditorView, pos: number): boolean { 119 | const doc = view.state.doc, 120 | $cell = cellAround(doc.resolve(pos)); 121 | if (!$cell) return false; 122 | view.dispatch(view.state.tr.setSelection(new CellSelection($cell))); 123 | return true; 124 | } 125 | 126 | /** 127 | * @public 128 | */ 129 | export function handlePaste( 130 | view: EditorView, 131 | _: ClipboardEvent, 132 | slice: Slice, 133 | ): boolean { 134 | if (!isInTable(view.state)) return false; 135 | let cells = pastedCells(slice); 136 | const sel = view.state.selection; 137 | if (sel instanceof CellSelection) { 138 | if (!cells) 139 | cells = { 140 | width: 1, 141 | height: 1, 142 | rows: [ 143 | Fragment.from( 144 | fitSlice(tableNodeTypes(view.state.schema).cell, slice), 145 | ), 146 | ], 147 | }; 148 | const table = sel.$anchorCell.node(-1); 149 | const start = sel.$anchorCell.start(-1); 150 | const rect = TableMap.get(table).rectBetween( 151 | sel.$anchorCell.pos - start, 152 | sel.$headCell.pos - start, 153 | ); 154 | cells = clipCells(cells, rect.right - rect.left, rect.bottom - rect.top); 155 | insertCells(view.state, view.dispatch, start, rect, cells); 156 | return true; 157 | } else if (cells) { 158 | const $cell = selectionCell(view.state); 159 | const start = $cell.start(-1); 160 | insertCells( 161 | view.state, 162 | view.dispatch, 163 | start, 164 | TableMap.get($cell.node(-1)).findCell($cell.pos - start), 165 | cells, 166 | ); 167 | return true; 168 | } else { 169 | return false; 170 | } 171 | } 172 | 173 | export function handleMouseDown( 174 | view: EditorView, 175 | startEvent: MouseEvent, 176 | ): void { 177 | // Only handle mouse down events for the main button (usually the left button). 178 | // This ensures that the cell selection won't be triggered when trying to open 179 | // the context menu. 180 | if (startEvent.button != 0) return; 181 | 182 | if (startEvent.ctrlKey || startEvent.metaKey) return; 183 | 184 | const startDOMCell = domInCell(view, startEvent.target as Node); 185 | let $anchor; 186 | if (startEvent.shiftKey && view.state.selection instanceof CellSelection) { 187 | // Adding to an existing cell selection 188 | setCellSelection(view.state.selection.$anchorCell, startEvent); 189 | startEvent.preventDefault(); 190 | } else if ( 191 | startEvent.shiftKey && 192 | startDOMCell && 193 | ($anchor = cellAround(view.state.selection.$anchor)) != null && 194 | cellUnderMouse(view, startEvent)?.pos != $anchor.pos 195 | ) { 196 | // Adding to a selection that starts in another cell (causing a 197 | // cell selection to be created). 198 | setCellSelection($anchor, startEvent); 199 | startEvent.preventDefault(); 200 | } else if (!startDOMCell) { 201 | // Not in a cell, let the default behavior happen. 202 | return; 203 | } 204 | 205 | // Create and dispatch a cell selection between the given anchor and 206 | // the position under the mouse. 207 | function setCellSelection($anchor: ResolvedPos, event: MouseEvent): void { 208 | let $head = cellUnderMouse(view, event); 209 | const starting = tableEditingKey.getState(view.state) == null; 210 | if (!$head || !inSameTable($anchor, $head)) { 211 | if (starting) $head = $anchor; 212 | else return; 213 | } 214 | const selection = new CellSelection($anchor, $head); 215 | if (starting || !view.state.selection.eq(selection)) { 216 | const tr = view.state.tr.setSelection(selection); 217 | if (starting) tr.setMeta(tableEditingKey, $anchor.pos); 218 | view.dispatch(tr); 219 | } 220 | } 221 | 222 | // Stop listening to mouse motion events. 223 | function stop(): void { 224 | view.root.removeEventListener('mouseup', stop); 225 | view.root.removeEventListener('dragstart', stop); 226 | view.root.removeEventListener('mousemove', move); 227 | if (tableEditingKey.getState(view.state) != null) 228 | view.dispatch(view.state.tr.setMeta(tableEditingKey, -1)); 229 | } 230 | 231 | function move(_event: Event): void { 232 | const event = _event as MouseEvent; 233 | const anchor = tableEditingKey.getState(view.state); 234 | let $anchor; 235 | if (anchor != null) { 236 | // Continuing an existing cross-cell selection 237 | $anchor = view.state.doc.resolve(anchor); 238 | } else if (domInCell(view, event.target as Node) != startDOMCell) { 239 | // Moving out of the initial cell -- start a new cell selection 240 | $anchor = cellUnderMouse(view, startEvent); 241 | if (!$anchor) return stop(); 242 | } 243 | if ($anchor) setCellSelection($anchor, event); 244 | } 245 | 246 | view.root.addEventListener('mouseup', stop); 247 | view.root.addEventListener('dragstart', stop); 248 | view.root.addEventListener('mousemove', move); 249 | } 250 | 251 | // Check whether the cursor is at the end of a cell (so that further 252 | // motion would move out of the cell) 253 | function atEndOfCell(view: EditorView, axis: Axis, dir: number): null | number { 254 | if (!(view.state.selection instanceof TextSelection)) return null; 255 | const { $head } = view.state.selection; 256 | for (let d = $head.depth - 1; d >= 0; d--) { 257 | const parent = $head.node(d), 258 | index = dir < 0 ? $head.index(d) : $head.indexAfter(d); 259 | if (index != (dir < 0 ? 0 : parent.childCount)) return null; 260 | if ( 261 | parent.type.spec.tableRole == 'cell' || 262 | parent.type.spec.tableRole == 'header_cell' 263 | ) { 264 | const cellPos = $head.before(d); 265 | const dirStr: 'up' | 'down' | 'left' | 'right' = 266 | axis == 'vert' ? (dir > 0 ? 'down' : 'up') : dir > 0 ? 'right' : 'left'; 267 | return view.endOfTextblock(dirStr) ? cellPos : null; 268 | } 269 | } 270 | return null; 271 | } 272 | 273 | function domInCell(view: EditorView, dom: Node | null): Node | null { 274 | for (; dom && dom != view.dom; dom = dom.parentNode) { 275 | if (dom.nodeName == 'TD' || dom.nodeName == 'TH') { 276 | return dom; 277 | } 278 | } 279 | return null; 280 | } 281 | 282 | function cellUnderMouse( 283 | view: EditorView, 284 | event: MouseEvent, 285 | ): ResolvedPos | null { 286 | const mousePos = view.posAtCoords({ 287 | left: event.clientX, 288 | top: event.clientY, 289 | }); 290 | if (!mousePos) return null; 291 | // Prefer `inside` position for better accuracy with merged cells (rowspan/colspan), 292 | // but fall back to `pos` if `inside` doesn't resolve to a valid cell 293 | let { inside, pos } = mousePos; 294 | return ( 295 | (inside >= 0 && cellAround(view.state.doc.resolve(inside))) || 296 | cellAround(view.state.doc.resolve(pos)) 297 | ); 298 | } 299 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ProseMirror table module 2 | 3 | This module defines a schema extension to support tables with 4 | rowspan/colspan support, a custom selection class for cell selections 5 | in such a table, a plugin to manage such selections and enforce 6 | invariants on such tables, and a number of commands to work with 7 | tables. 8 | 9 | The `demo` directory contains a `demo.ts` and `index.html`, which 10 | can be built with `pnpm run build_demo` to show a simple demo of how the 11 | module can be used. 12 | 13 | ## [Live Demo](https://prosemirror-tables.netlify.app/) 14 | 15 | ## Documentation 16 | 17 | The module's main file exports everything you need to work with it. 18 | The first thing you'll probably want to do is create a table-enabled 19 | schema. That's what `tableNodes` is for: 20 | 21 | * **`tableNodes`**`(options: Object) → Object`\ 22 | This function creates a set of [node 23 | specs](http://prosemirror.net/docs/ref/#model.SchemaSpec.nodes) for 24 | `table`, `table_row`, and `table_cell` nodes types as used by this 25 | module. The result can then be added to the set of nodes when 26 | creating a a schema. 27 | 28 | * **`options`**`: Object`\ 29 | The following options are understood: 30 | 31 | * **`tableGroup`**`: ?string`\ 32 | A group name (something like `"block"`) to add to the table 33 | node type. 34 | 35 | * **`cellContent`**`: string`\ 36 | The content expression for table cells. 37 | 38 | * **`cellAttributes`**`: ?Object`\ 39 | Additional attributes to add to cells. Maps attribute names to 40 | objects with the following properties: 41 | 42 | * **`default`**`: any`\ 43 | The attribute's default value. 44 | 45 | * **`getFromDOM`**`: ?fn(dom.Node) → any`\ 46 | A function to read the attribute's value from a DOM node. 47 | 48 | * **`setDOMAttr`**`: ?fn(value: any, attrs: Object)`\ 49 | A function to add the attribute's value to an attribute 50 | object that's used to render the cell's DOM. 51 | 52 | 53 | * **`tableEditing`**`() → Plugin`\ 54 | Creates a [plugin](http://prosemirror.net/docs/ref/#state.Plugin) 55 | that, when added to an editor, enables cell-selection, handles 56 | cell-based copy/paste, and makes sure tables stay well-formed (each 57 | row has the same width, and cells don't overlap). 58 | 59 | You should probably put this plugin near the end of your array of 60 | plugins, since it handles mouse and arrow key events in tables 61 | rather broadly, and other plugins, like the gap cursor or the 62 | column-width dragging plugin, might want to get a turn first to 63 | perform more specific behavior. 64 | 65 | 66 | ### class CellSelection extends Selection 67 | 68 | A [`Selection`](http://prosemirror.net/docs/ref/#state.Selection) 69 | subclass that represents a cell selection spanning part of a table. 70 | With the plugin enabled, these will be created when the user 71 | selects across cells, and will be drawn by giving selected cells a 72 | `selectedCell` CSS class. 73 | 74 | * `new `**`CellSelection`**`($anchorCell: ResolvedPos, $headCell: ?ResolvedPos = $anchorCell)`\ 75 | A table selection is identified by its anchor and head cells. The 76 | positions given to this constructor should point _before_ two 77 | cells in the same table. They may be the same, to select a single 78 | cell. 79 | 80 | * **`$anchorCell`**`: ResolvedPos`\ 81 | A resolved position pointing _in front of_ the anchor cell (the one 82 | that doesn't move when extending the selection). 83 | 84 | * **`$headCell`**`: ResolvedPos`\ 85 | A resolved position pointing in front of the head cell (the one 86 | moves when extending the selection). 87 | 88 | * **`content`**`() → Slice`\ 89 | Returns a rectangular slice of table rows containing the selected 90 | cells. 91 | 92 | * **`isColSelection`**`() → bool`\ 93 | True if this selection goes all the way from the top to the 94 | bottom of the table. 95 | 96 | * **`isRowSelection`**`() → bool`\ 97 | True if this selection goes all the way from the left to the 98 | right of the table. 99 | 100 | * `static `**`colSelection`**`($anchorCell: ResolvedPos, $headCell: ?ResolvedPos = $anchorCell) → CellSelection`\ 101 | Returns the smallest column selection that covers the given anchor 102 | and head cell. 103 | 104 | * `static `**`rowSelection`**`($anchorCell: ResolvedPos, $headCell: ?ResolvedPos = $anchorCell) → CellSelection`\ 105 | Returns the smallest row selection that covers the given anchor 106 | and head cell. 107 | 108 | * `static `**`create`**`(doc: Node, anchorCell: number, headCell: ?number = anchorCell) → CellSelection` 109 | 110 | 111 | ### Commands 112 | 113 | The following commands can be used to make table-editing functionality 114 | available to users. 115 | 116 | * **`addColumnBefore`**`(state: EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 117 | Command to add a column before the column with the selection. 118 | 119 | 120 | * **`addColumnAfter`**`(state: EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 121 | Command to add a column after the column with the selection. 122 | 123 | 124 | * **`deleteColumn`**`(state: EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 125 | Command function that removes the selected columns from a table. 126 | 127 | 128 | * **`addRowBefore`**`(state: EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 129 | Add a table row before the selection. 130 | 131 | 132 | * **`addRowAfter`**`(state: EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 133 | Add a table row after the selection. 134 | 135 | 136 | * **`deleteRow`**`(state: EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 137 | Remove the selected rows from a table. 138 | 139 | 140 | * **`mergeCells`**`(state: EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 141 | Merge the selected cells into a single cell. Only available when 142 | the selected cells' outline forms a rectangle. 143 | 144 | 145 | * **`splitCell`**`(state: EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 146 | Split a selected cell, whose rowpan or colspan is greater than one, 147 | into smaller cells. Use the first cell type for the new cells. 148 | 149 | 150 | * **`splitCellWithType`**`(getType: fn({row: number, col: number, node: Node}) → NodeType) → fn(EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 151 | Split a selected cell, whose rowpan or colspan is greater than one, 152 | into smaller cells with the cell type (th, td) returned by getType function. 153 | 154 | 155 | * **`setCellAttr`**`(name: string, value: any) → fn(EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 156 | Returns a command that sets the given attribute to the given value, 157 | and is only available when the currently selected cell doesn't 158 | already have that attribute set to that value. 159 | 160 | 161 | * **`toggleHeaderRow`**`(EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 162 | Toggles whether the selected row contains header cells. 163 | 164 | 165 | * **`toggleHeaderColumn`**`(EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 166 | Toggles whether the selected column contains header cells. 167 | 168 | 169 | * **`toggleHeaderCell`**`(EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 170 | Toggles whether the selected cells are header cells. 171 | 172 | 173 | * **`toggleHeader`**`(type: string, options: ?{useDeprecatedLogic: bool}) → fn(EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 174 | Toggles between row/column header and normal cells (Only applies to first row/column). 175 | For deprecated behavior pass `useDeprecatedLogic` in options with true. 176 | 177 | 178 | * **`goToNextCell`**`(direction: number) → fn(EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 179 | Returns a command for selecting the next (direction=1) or previous 180 | (direction=-1) cell in a table. 181 | 182 | 183 | * **`deleteTable`**`(state: EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 184 | Deletes the table around the selection, if any. 185 | 186 | 187 | * **`moveTableRow`**`(options: MoveTableRowOptions) → fn(EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 188 | Move a table row from one index to another. 189 | 190 | 191 | * **`moveTableColumn`**`(options: MoveTableColumnOptions) → fn(EditorState, dispatch: ?fn(tr: Transaction)) → bool`\ 192 | Move a table column from one index to another. 193 | 194 | 195 | ### Utilities 196 | 197 | * **`fixTables`**`(state: EditorState, oldState: ?EditorState) → ?Transaction`\ 198 | Inspect all tables in the given state's document and return a 199 | transaction that fixes them, if necessary. If `oldState` was 200 | provided, that is assumed to hold a previous, known-good state, 201 | which will be used to avoid re-scanning unchanged parts of the 202 | document. 203 | 204 | * **`findTable`**`($pos: ResolvedPos) → ?FindNodeResult`\ 205 | Find the closest table node that contains the given position, if any. 206 | 207 | * **`findCellRange`**`($pos: ResolvedPos, anchorHit: ?number, headHit: ?number) → ?[ResolvedPos, ResolvedPos]`\ 208 | Find the anchor and head cell in the same table by using the given position and optional hit positions, or fallback to the selection's anchor and head. 209 | 210 | * **`findCellPos`**`(doc: Node, pos: number) → ?ResolvedPos`\ 211 | Find a resolved pos of a cell by using the given position as a hit point. 212 | 213 | ### class TableMap 214 | 215 | A table map describes the structore of a given table. To avoid 216 | recomputing them all the time, they are cached per table node. To 217 | be able to do that, positions saved in the map are relative to the 218 | start of the table, rather than the start of the document. 219 | 220 | * **`width`**`: number`\ 221 | The width of the table 222 | 223 | * **`height`**`: number`\ 224 | The table's height 225 | 226 | * **`map`**`: [number]`\ 227 | A width * height array with the start position of 228 | the cell covering that part of the table in each slot 229 | 230 | * **`findCell`**`(pos: number) → Rect`\ 231 | Find the dimensions of the cell at the given position. 232 | 233 | * **`colCount`**`(pos: number) → number`\ 234 | Find the left side of the cell at the given position. 235 | 236 | * **`nextCell`**`(pos: number, axis: string, dir: number) → ?number`\ 237 | Find the next cell in the given direction, starting from the cell 238 | at `pos`, if any. 239 | 240 | * **`rectBetween`**`(a: number, b: number) → Rect`\ 241 | Get the rectangle spanning the two given cells. 242 | 243 | * **`cellsInRect`**`(rect: Rect) → [number]`\ 244 | Return the position of all cells that have the top left corner in 245 | the given rectangle. 246 | 247 | * **`positionAt`**`(row: number, col: number, table: Node) → number`\ 248 | Return the position at which the cell at the given row and column 249 | starts, or would start, if a cell started there. 250 | 251 | * `static `**`get`**`(table: Node) → TableMap`\ 252 | Find the table map for the given table node. 253 | 254 | 255 | -------------------------------------------------------------------------------- /src/tablemap.ts: -------------------------------------------------------------------------------- 1 | // Because working with row and column-spanning cells is not quite 2 | // trivial, this code builds up a descriptive structure for a given 3 | // table node. The structures are cached with the (persistent) table 4 | // nodes as key, so that they only have to be recomputed when the 5 | // content of the table changes. 6 | // 7 | // This does mean that they have to store table-relative, not 8 | // document-relative positions. So code that uses them will typically 9 | // compute the start position of the table and offset positions passed 10 | // to or gotten from this structure by that amount. 11 | import type { Attrs, Node } from 'prosemirror-model'; 12 | 13 | import type { CellAttrs } from './util'; 14 | 15 | /** 16 | * @public 17 | */ 18 | export type ColWidths = number[]; 19 | 20 | /** 21 | * @public 22 | */ 23 | export type Problem = 24 | | { 25 | type: 'colwidth mismatch'; 26 | pos: number; 27 | colwidth: ColWidths; 28 | } 29 | | { 30 | type: 'collision'; 31 | pos: number; 32 | row: number; 33 | n: number; 34 | } 35 | | { 36 | type: 'missing'; 37 | row: number; 38 | n: number; 39 | } 40 | | { 41 | type: 'overlong_rowspan'; 42 | pos: number; 43 | n: number; 44 | } 45 | | { 46 | type: 'zero_sized'; 47 | }; 48 | 49 | let readFromCache: (key: Node) => TableMap | undefined; 50 | let addToCache: (key: Node, value: TableMap) => TableMap; 51 | 52 | // Prefer using a weak map to cache table maps. Fall back on a 53 | // fixed-size cache if that's not supported. 54 | if (typeof WeakMap != 'undefined') { 55 | let cache = new WeakMap(); 56 | readFromCache = (key) => cache.get(key); 57 | addToCache = (key, value) => { 58 | cache.set(key, value); 59 | return value; 60 | }; 61 | } else { 62 | const cache: (Node | TableMap)[] = []; 63 | const cacheSize = 10; 64 | let cachePos = 0; 65 | readFromCache = (key) => { 66 | for (let i = 0; i < cache.length; i += 2) 67 | if (cache[i] == key) return cache[i + 1] as TableMap; 68 | }; 69 | addToCache = (key, value) => { 70 | if (cachePos == cacheSize) cachePos = 0; 71 | cache[cachePos++] = key; 72 | return (cache[cachePos++] = value); 73 | }; 74 | } 75 | 76 | /** 77 | * @public 78 | */ 79 | export interface Rect { 80 | left: number; 81 | top: number; 82 | right: number; 83 | bottom: number; 84 | } 85 | 86 | /** 87 | * A table map describes the structure of a given table. To avoid 88 | * recomputing them all the time, they are cached per table node. To 89 | * be able to do that, positions saved in the map are relative to the 90 | * start of the table, rather than the start of the document. 91 | * 92 | * @public 93 | */ 94 | export class TableMap { 95 | constructor( 96 | /** 97 | * The number of columns 98 | */ 99 | public width: number, 100 | /** 101 | * The number of rows 102 | */ 103 | public height: number, 104 | /** 105 | * A width * height array with the start position of 106 | * the cell covering that part of the table in each slot 107 | */ 108 | public map: number[], 109 | /** 110 | * An optional array of problems (cell overlap or non-rectangular 111 | * shape) for the table, used by the table normalizer. 112 | */ 113 | public problems: Problem[] | null, 114 | ) {} 115 | 116 | // Find the dimensions of the cell at the given position. 117 | findCell(pos: number): Rect { 118 | for (let i = 0; i < this.map.length; i++) { 119 | const curPos = this.map[i]; 120 | if (curPos != pos) continue; 121 | 122 | const left = i % this.width; 123 | const top = (i / this.width) | 0; 124 | let right = left + 1; 125 | let bottom = top + 1; 126 | 127 | for (let j = 1; right < this.width && this.map[i + j] == curPos; j++) { 128 | right++; 129 | } 130 | for ( 131 | let j = 1; 132 | bottom < this.height && this.map[i + this.width * j] == curPos; 133 | j++ 134 | ) { 135 | bottom++; 136 | } 137 | 138 | return { left, top, right, bottom }; 139 | } 140 | throw new RangeError(`No cell with offset ${pos} found`); 141 | } 142 | 143 | // Find the left side of the cell at the given position. 144 | colCount(pos: number): number { 145 | for (let i = 0; i < this.map.length; i++) { 146 | if (this.map[i] == pos) { 147 | return i % this.width; 148 | } 149 | } 150 | throw new RangeError(`No cell with offset ${pos} found`); 151 | } 152 | 153 | // Find the next cell in the given direction, starting from the cell 154 | // at `pos`, if any. 155 | nextCell(pos: number, axis: 'horiz' | 'vert', dir: number): null | number { 156 | const { left, right, top, bottom } = this.findCell(pos); 157 | if (axis == 'horiz') { 158 | if (dir < 0 ? left == 0 : right == this.width) return null; 159 | return this.map[top * this.width + (dir < 0 ? left - 1 : right)]; 160 | } else { 161 | if (dir < 0 ? top == 0 : bottom == this.height) return null; 162 | return this.map[left + this.width * (dir < 0 ? top - 1 : bottom)]; 163 | } 164 | } 165 | 166 | // Get the rectangle spanning the two given cells. 167 | rectBetween(a: number, b: number): Rect { 168 | const { 169 | left: leftA, 170 | right: rightA, 171 | top: topA, 172 | bottom: bottomA, 173 | } = this.findCell(a); 174 | const { 175 | left: leftB, 176 | right: rightB, 177 | top: topB, 178 | bottom: bottomB, 179 | } = this.findCell(b); 180 | return { 181 | left: Math.min(leftA, leftB), 182 | top: Math.min(topA, topB), 183 | right: Math.max(rightA, rightB), 184 | bottom: Math.max(bottomA, bottomB), 185 | }; 186 | } 187 | 188 | // Return the position of all cells that have the top left corner in 189 | // the given rectangle. 190 | cellsInRect(rect: Rect): number[] { 191 | const result: number[] = []; 192 | const seen: Record = {}; 193 | for (let row = rect.top; row < rect.bottom; row++) { 194 | for (let col = rect.left; col < rect.right; col++) { 195 | const index = row * this.width + col; 196 | const pos = this.map[index]; 197 | 198 | if (seen[pos]) continue; 199 | seen[pos] = true; 200 | 201 | if ( 202 | (col == rect.left && col && this.map[index - 1] == pos) || 203 | (row == rect.top && row && this.map[index - this.width] == pos) 204 | ) { 205 | continue; 206 | } 207 | result.push(pos); 208 | } 209 | } 210 | return result; 211 | } 212 | 213 | // Return the position at which the cell at the given row and column 214 | // starts, or would start, if a cell started there. 215 | positionAt(row: number, col: number, table: Node): number { 216 | for (let i = 0, rowStart = 0; ; i++) { 217 | const rowEnd = rowStart + table.child(i).nodeSize; 218 | if (i == row) { 219 | let index = col + row * this.width; 220 | const rowEndIndex = (row + 1) * this.width; 221 | // Skip past cells from previous rows (via rowspan) 222 | while (index < rowEndIndex && this.map[index] < rowStart) index++; 223 | return index == rowEndIndex ? rowEnd - 1 : this.map[index]; 224 | } 225 | rowStart = rowEnd; 226 | } 227 | } 228 | 229 | // Find the table map for the given table node. 230 | static get(table: Node): TableMap { 231 | return readFromCache(table) || addToCache(table, computeMap(table)); 232 | } 233 | } 234 | 235 | // Compute a table map. 236 | function computeMap(table: Node): TableMap { 237 | if (table.type.spec.tableRole != 'table') 238 | throw new RangeError('Not a table node: ' + table.type.name); 239 | const width = findWidth(table), 240 | height = table.childCount; 241 | const map = []; 242 | let mapPos = 0; 243 | let problems: Problem[] | null = null; 244 | const colWidths: ColWidths = []; 245 | for (let i = 0, e = width * height; i < e; i++) map[i] = 0; 246 | 247 | for (let row = 0, pos = 0; row < height; row++) { 248 | const rowNode = table.child(row); 249 | pos++; 250 | for (let i = 0; ; i++) { 251 | while (mapPos < map.length && map[mapPos] != 0) mapPos++; 252 | if (i == rowNode.childCount) break; 253 | const cellNode = rowNode.child(i); 254 | const { colspan, rowspan, colwidth } = cellNode.attrs; 255 | for (let h = 0; h < rowspan; h++) { 256 | if (h + row >= height) { 257 | (problems || (problems = [])).push({ 258 | type: 'overlong_rowspan', 259 | pos, 260 | n: rowspan - h, 261 | }); 262 | break; 263 | } 264 | const start = mapPos + h * width; 265 | for (let w = 0; w < colspan; w++) { 266 | if (map[start + w] == 0) map[start + w] = pos; 267 | else 268 | (problems || (problems = [])).push({ 269 | type: 'collision', 270 | row, 271 | pos, 272 | n: colspan - w, 273 | }); 274 | const colW = colwidth && colwidth[w]; 275 | if (colW) { 276 | const widthIndex = ((start + w) % width) * 2, 277 | prev = colWidths[widthIndex]; 278 | if ( 279 | prev == null || 280 | (prev != colW && colWidths[widthIndex + 1] == 1) 281 | ) { 282 | colWidths[widthIndex] = colW; 283 | colWidths[widthIndex + 1] = 1; 284 | } else if (prev == colW) { 285 | colWidths[widthIndex + 1]++; 286 | } 287 | } 288 | } 289 | } 290 | mapPos += colspan; 291 | pos += cellNode.nodeSize; 292 | } 293 | const expectedPos = (row + 1) * width; 294 | let missing = 0; 295 | while (mapPos < expectedPos) if (map[mapPos++] == 0) missing++; 296 | if (missing) 297 | (problems || (problems = [])).push({ type: 'missing', row, n: missing }); 298 | pos++; 299 | } 300 | 301 | if (width === 0 || height === 0) 302 | (problems || (problems = [])).push({ type: 'zero_sized' }); 303 | 304 | const tableMap = new TableMap(width, height, map, problems); 305 | let badWidths = false; 306 | 307 | // For columns that have defined widths, but whose widths disagree 308 | // between rows, fix up the cells whose width doesn't match the 309 | // computed one. 310 | for (let i = 0; !badWidths && i < colWidths.length; i += 2) 311 | if (colWidths[i] != null && colWidths[i + 1] < height) badWidths = true; 312 | if (badWidths) findBadColWidths(tableMap, colWidths, table); 313 | 314 | return tableMap; 315 | } 316 | 317 | function findWidth(table: Node): number { 318 | let width = -1; 319 | let hasRowSpan = false; 320 | for (let row = 0; row < table.childCount; row++) { 321 | const rowNode = table.child(row); 322 | let rowWidth = 0; 323 | if (hasRowSpan) 324 | for (let j = 0; j < row; j++) { 325 | const prevRow = table.child(j); 326 | for (let i = 0; i < prevRow.childCount; i++) { 327 | const cell = prevRow.child(i); 328 | if (j + cell.attrs.rowspan > row) rowWidth += cell.attrs.colspan; 329 | } 330 | } 331 | for (let i = 0; i < rowNode.childCount; i++) { 332 | const cell = rowNode.child(i); 333 | rowWidth += cell.attrs.colspan; 334 | if (cell.attrs.rowspan > 1) hasRowSpan = true; 335 | } 336 | if (width == -1) width = rowWidth; 337 | else if (width != rowWidth) width = Math.max(width, rowWidth); 338 | } 339 | return width; 340 | } 341 | 342 | function findBadColWidths( 343 | map: TableMap, 344 | colWidths: ColWidths, 345 | table: Node, 346 | ): void { 347 | if (!map.problems) map.problems = []; 348 | const seen: Record = {}; 349 | for (let i = 0; i < map.map.length; i++) { 350 | const pos = map.map[i]; 351 | if (seen[pos]) continue; 352 | seen[pos] = true; 353 | const node = table.nodeAt(pos); 354 | if (!node) { 355 | throw new RangeError(`No cell with offset ${pos} found`); 356 | } 357 | 358 | let updated = null; 359 | const attrs = node.attrs as CellAttrs; 360 | for (let j = 0; j < attrs.colspan; j++) { 361 | const col = (i + j) % map.width; 362 | const colWidth = colWidths[col * 2]; 363 | if ( 364 | colWidth != null && 365 | (!attrs.colwidth || attrs.colwidth[j] != colWidth) 366 | ) 367 | (updated || (updated = freshColWidth(attrs)))[j] = colWidth; 368 | } 369 | if (updated) 370 | map.problems.unshift({ 371 | type: 'colwidth mismatch', 372 | pos, 373 | colwidth: updated, 374 | }); 375 | } 376 | } 377 | 378 | function freshColWidth(attrs: Attrs): ColWidths { 379 | if (attrs.colwidth) return attrs.colwidth.slice(); 380 | const result: ColWidths = []; 381 | for (let i = 0; i < attrs.colspan; i++) result.push(0); 382 | return result; 383 | } 384 | -------------------------------------------------------------------------------- /src/copypaste.ts: -------------------------------------------------------------------------------- 1 | // Utilities used for copy/paste handling. 2 | // 3 | // This module handles pasting cell content into tables, or pasting 4 | // anything into a cell selection, as replacing a block of cells with 5 | // the content of the selection. When pasting cells into a cell, that 6 | // involves placing the block of pasted content so that its top left 7 | // aligns with the selection cell, optionally extending the table to 8 | // the right or bottom to make sure it is large enough. Pasting into a 9 | // cell selection is different, here the cells in the selection are 10 | // clipped to the selection's rectangle, optionally repeating the 11 | // pasted cells when they are smaller than the selection. 12 | 13 | import type { Node, NodeType, Schema } from 'prosemirror-model'; 14 | import { Fragment, Slice } from 'prosemirror-model'; 15 | import type { EditorState, Transaction } from 'prosemirror-state'; 16 | import { Transform } from 'prosemirror-transform'; 17 | 18 | import { CellSelection } from './cellselection'; 19 | import { tableNodeTypes } from './schema'; 20 | import type { ColWidths, Rect } from './tablemap'; 21 | import { TableMap } from './tablemap'; 22 | import type { CellAttrs } from './util'; 23 | import { removeColSpan } from './util'; 24 | 25 | /** 26 | * @internal 27 | */ 28 | export type Area = { width: number; height: number; rows: Fragment[] }; 29 | 30 | // Utilities to help with copying and pasting table cells 31 | 32 | /** 33 | * Get a rectangular area of cells from a slice, or null if the outer 34 | * nodes of the slice aren't table cells or rows. 35 | * 36 | * @internal 37 | */ 38 | export function pastedCells(slice: Slice): Area | null { 39 | if (slice.size === 0) return null; 40 | let { content, openStart, openEnd } = slice; 41 | while ( 42 | content.childCount == 1 && 43 | ((openStart > 0 && openEnd > 0) || 44 | content.child(0).type.spec.tableRole == 'table') 45 | ) { 46 | openStart--; 47 | openEnd--; 48 | content = content.child(0).content; 49 | } 50 | const first = content.child(0); 51 | const role = first.type.spec.tableRole; 52 | const schema = first.type.schema, 53 | rows = []; 54 | if (role == 'row') { 55 | for (let i = 0; i < content.childCount; i++) { 56 | let cells = content.child(i).content; 57 | const left = i ? 0 : Math.max(0, openStart - 1); 58 | const right = i < content.childCount - 1 ? 0 : Math.max(0, openEnd - 1); 59 | if (left || right) 60 | cells = fitSlice( 61 | tableNodeTypes(schema).row, 62 | new Slice(cells, left, right), 63 | ).content; 64 | rows.push(cells); 65 | } 66 | } else if (role == 'cell' || role == 'header_cell') { 67 | rows.push( 68 | openStart || openEnd 69 | ? fitSlice( 70 | tableNodeTypes(schema).row, 71 | new Slice(content, openStart, openEnd), 72 | ).content 73 | : content, 74 | ); 75 | } else { 76 | return null; 77 | } 78 | return ensureRectangular(schema, rows); 79 | } 80 | 81 | // Compute the width and height of a set of cells, and make sure each 82 | // row has the same number of cells. 83 | function ensureRectangular(schema: Schema, rows: Fragment[]): Area { 84 | const widths: ColWidths = []; 85 | for (let i = 0; i < rows.length; i++) { 86 | const row = rows[i]; 87 | for (let j = row.childCount - 1; j >= 0; j--) { 88 | const { rowspan, colspan } = row.child(j).attrs; 89 | for (let r = i; r < i + rowspan; r++) 90 | widths[r] = (widths[r] || 0) + colspan; 91 | } 92 | } 93 | let width = 0; 94 | for (let r = 0; r < widths.length; r++) width = Math.max(width, widths[r]); 95 | for (let r = 0; r < widths.length; r++) { 96 | if (r >= rows.length) rows.push(Fragment.empty); 97 | if (widths[r] < width) { 98 | const empty = tableNodeTypes(schema).cell.createAndFill()!; 99 | const cells = []; 100 | for (let i = widths[r]; i < width; i++) { 101 | cells.push(empty); 102 | } 103 | rows[r] = rows[r].append(Fragment.from(cells)); 104 | } 105 | } 106 | return { height: rows.length, width, rows }; 107 | } 108 | 109 | export function fitSlice(nodeType: NodeType, slice: Slice): Node { 110 | const node = nodeType.createAndFill()!; 111 | const tr = new Transform(node).replace(0, node.content.size, slice); 112 | return tr.doc; 113 | } 114 | 115 | /** 116 | * Clip or extend (repeat) the given set of cells to cover the given 117 | * width and height. Will clip rowspan/colspan cells at the edges when 118 | * they stick out. 119 | * 120 | * @internal 121 | */ 122 | export function clipCells( 123 | { width, height, rows }: Area, 124 | newWidth: number, 125 | newHeight: number, 126 | ): Area { 127 | if (width != newWidth) { 128 | const added: number[] = []; 129 | const newRows: Fragment[] = []; 130 | for (let row = 0; row < rows.length; row++) { 131 | const frag = rows[row], 132 | cells = []; 133 | for (let col = added[row] || 0, i = 0; col < newWidth; i++) { 134 | let cell = frag.child(i % frag.childCount); 135 | if (col + cell.attrs.colspan > newWidth) 136 | cell = cell.type.createChecked( 137 | removeColSpan( 138 | cell.attrs as CellAttrs, 139 | cell.attrs.colspan, 140 | col + cell.attrs.colspan - newWidth, 141 | ), 142 | cell.content, 143 | ); 144 | cells.push(cell); 145 | col += cell.attrs.colspan; 146 | for (let j = 1; j < cell.attrs.rowspan; j++) 147 | added[row + j] = (added[row + j] || 0) + cell.attrs.colspan; 148 | } 149 | newRows.push(Fragment.from(cells)); 150 | } 151 | rows = newRows; 152 | width = newWidth; 153 | } 154 | 155 | if (height != newHeight) { 156 | const newRows = []; 157 | for (let row = 0, i = 0; row < newHeight; row++, i++) { 158 | const cells = [], 159 | source = rows[i % height]; 160 | for (let j = 0; j < source.childCount; j++) { 161 | let cell = source.child(j); 162 | if (row + cell.attrs.rowspan > newHeight) 163 | cell = cell.type.create( 164 | { 165 | ...cell.attrs, 166 | rowspan: Math.max(1, newHeight - cell.attrs.rowspan), 167 | }, 168 | cell.content, 169 | ); 170 | cells.push(cell); 171 | } 172 | newRows.push(Fragment.from(cells)); 173 | } 174 | rows = newRows; 175 | height = newHeight; 176 | } 177 | 178 | return { width, height, rows }; 179 | } 180 | 181 | // Make sure a table has at least the given width and height. Return 182 | // true if something was changed. 183 | function growTable( 184 | tr: Transaction, 185 | map: TableMap, 186 | table: Node, 187 | start: number, 188 | width: number, 189 | height: number, 190 | mapFrom: number, 191 | ): boolean { 192 | const schema = tr.doc.type.schema; 193 | const types = tableNodeTypes(schema); 194 | let empty; 195 | let emptyHead; 196 | if (width > map.width) { 197 | for (let row = 0, rowEnd = 0; row < map.height; row++) { 198 | const rowNode = table.child(row); 199 | rowEnd += rowNode.nodeSize; 200 | const cells: Node[] = []; 201 | let add: Node; 202 | if (rowNode.lastChild == null || rowNode.lastChild.type == types.cell) 203 | add = empty || (empty = types.cell.createAndFill()!); 204 | else add = emptyHead || (emptyHead = types.header_cell.createAndFill()!); 205 | for (let i = map.width; i < width; i++) cells.push(add); 206 | tr.insert(tr.mapping.slice(mapFrom).map(rowEnd - 1 + start), cells); 207 | } 208 | } 209 | if (height > map.height) { 210 | const cells = []; 211 | for ( 212 | let i = 0, start = (map.height - 1) * map.width; 213 | i < Math.max(map.width, width); 214 | i++ 215 | ) { 216 | const header = 217 | i >= map.width 218 | ? false 219 | : table.nodeAt(map.map[start + i])!.type == types.header_cell; 220 | cells.push( 221 | header 222 | ? emptyHead || (emptyHead = types.header_cell.createAndFill()!) 223 | : empty || (empty = types.cell.createAndFill()!), 224 | ); 225 | } 226 | 227 | const emptyRow = types.row.create(null, Fragment.from(cells)), 228 | rows = []; 229 | for (let i = map.height; i < height; i++) rows.push(emptyRow); 230 | tr.insert(tr.mapping.slice(mapFrom).map(start + table.nodeSize - 2), rows); 231 | } 232 | return !!(empty || emptyHead); 233 | } 234 | 235 | // Make sure the given line (left, top) to (right, top) doesn't cross 236 | // any rowspan cells by splitting cells that cross it. Return true if 237 | // something changed. 238 | function isolateHorizontal( 239 | tr: Transaction, 240 | map: TableMap, 241 | table: Node, 242 | start: number, 243 | left: number, 244 | right: number, 245 | top: number, 246 | mapFrom: number, 247 | ): boolean { 248 | if (top == 0 || top == map.height) return false; 249 | let found = false; 250 | for (let col = left; col < right; col++) { 251 | const index = top * map.width + col, 252 | pos = map.map[index]; 253 | if (map.map[index - map.width] == pos) { 254 | found = true; 255 | const cell = table.nodeAt(pos)!; 256 | const { top: cellTop, left: cellLeft } = map.findCell(pos); 257 | tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(pos + start), null, { 258 | ...cell.attrs, 259 | rowspan: top - cellTop, 260 | }); 261 | tr.insert( 262 | tr.mapping.slice(mapFrom).map(map.positionAt(top, cellLeft, table)), 263 | cell.type.createAndFill({ 264 | ...cell.attrs, 265 | rowspan: cellTop + cell.attrs.rowspan - top, 266 | })!, 267 | ); 268 | col += cell.attrs.colspan - 1; 269 | } 270 | } 271 | return found; 272 | } 273 | 274 | // Make sure the given line (left, top) to (left, bottom) doesn't 275 | // cross any colspan cells by splitting cells that cross it. Return 276 | // true if something changed. 277 | function isolateVertical( 278 | tr: Transaction, 279 | map: TableMap, 280 | table: Node, 281 | start: number, 282 | top: number, 283 | bottom: number, 284 | left: number, 285 | mapFrom: number, 286 | ): boolean { 287 | if (left == 0 || left == map.width) return false; 288 | let found = false; 289 | for (let row = top; row < bottom; row++) { 290 | const index = row * map.width + left, 291 | pos = map.map[index]; 292 | if (map.map[index - 1] == pos) { 293 | found = true; 294 | const cell = table.nodeAt(pos)!; 295 | const cellLeft = map.colCount(pos); 296 | const updatePos = tr.mapping.slice(mapFrom).map(pos + start); 297 | tr.setNodeMarkup( 298 | updatePos, 299 | null, 300 | removeColSpan( 301 | cell.attrs as CellAttrs, 302 | left - cellLeft, 303 | cell.attrs.colspan - (left - cellLeft), 304 | ), 305 | ); 306 | tr.insert( 307 | updatePos + cell.nodeSize, 308 | cell.type.createAndFill( 309 | removeColSpan(cell.attrs as CellAttrs, 0, left - cellLeft), 310 | )!, 311 | ); 312 | row += cell.attrs.rowspan - 1; 313 | } 314 | } 315 | return found; 316 | } 317 | 318 | /** 319 | * Insert the given set of cells (as returned by `pastedCells`) into a 320 | * table, at the position pointed at by rect. 321 | * 322 | * @internal 323 | */ 324 | export function insertCells( 325 | state: EditorState, 326 | dispatch: (tr: Transaction) => void, 327 | tableStart: number, 328 | rect: Rect, 329 | cells: Area, 330 | ): void { 331 | let table = tableStart ? state.doc.nodeAt(tableStart - 1) : state.doc; 332 | if (!table) { 333 | throw new Error('No table found'); 334 | } 335 | let map = TableMap.get(table); 336 | const { top, left } = rect; 337 | const right = left + cells.width, 338 | bottom = top + cells.height; 339 | const tr = state.tr; 340 | let mapFrom = 0; 341 | 342 | function recomp(): void { 343 | table = tableStart ? tr.doc.nodeAt(tableStart - 1) : tr.doc; 344 | if (!table) { 345 | throw new Error('No table found'); 346 | } 347 | map = TableMap.get(table); 348 | mapFrom = tr.mapping.maps.length; 349 | } 350 | 351 | // Prepare the table to be large enough and not have any cells 352 | // crossing the boundaries of the rectangle that we want to 353 | // insert into. If anything about it changes, recompute the table 354 | // map so that subsequent operations can see the current shape. 355 | if (growTable(tr, map, table, tableStart, right, bottom, mapFrom)) recomp(); 356 | if (isolateHorizontal(tr, map, table, tableStart, left, right, top, mapFrom)) 357 | recomp(); 358 | if ( 359 | isolateHorizontal(tr, map, table, tableStart, left, right, bottom, mapFrom) 360 | ) 361 | recomp(); 362 | if (isolateVertical(tr, map, table, tableStart, top, bottom, left, mapFrom)) 363 | recomp(); 364 | if (isolateVertical(tr, map, table, tableStart, top, bottom, right, mapFrom)) 365 | recomp(); 366 | 367 | for (let row = top; row < bottom; row++) { 368 | const from = map.positionAt(row, left, table), 369 | to = map.positionAt(row, right, table); 370 | tr.replace( 371 | tr.mapping.slice(mapFrom).map(from + tableStart), 372 | tr.mapping.slice(mapFrom).map(to + tableStart), 373 | new Slice(cells.rows[row - top], 0, 0), 374 | ); 375 | } 376 | recomp(); 377 | tr.setSelection( 378 | new CellSelection( 379 | tr.doc.resolve(tableStart + map.positionAt(top, left, table)), 380 | tr.doc.resolve(tableStart + map.positionAt(bottom - 1, right - 1, table)), 381 | ), 382 | ); 383 | dispatch(tr); 384 | } 385 | -------------------------------------------------------------------------------- /src/columnresizing.ts: -------------------------------------------------------------------------------- 1 | import type { Attrs, Node as ProsemirrorNode } from 'prosemirror-model'; 2 | import type { EditorState, Transaction } from 'prosemirror-state'; 3 | import { Plugin, PluginKey } from 'prosemirror-state'; 4 | import type { EditorView, NodeView } from 'prosemirror-view'; 5 | import { Decoration, DecorationSet } from 'prosemirror-view'; 6 | 7 | import { tableNodeTypes } from './schema'; 8 | import { TableMap } from './tablemap'; 9 | import { TableView, updateColumnsOnResize } from './tableview'; 10 | import type { CellAttrs } from './util'; 11 | import { cellAround, pointsAtCell } from './util'; 12 | 13 | /** 14 | * @public 15 | */ 16 | export const columnResizingPluginKey = new PluginKey( 17 | 'tableColumnResizing', 18 | ); 19 | 20 | /** 21 | * @public 22 | */ 23 | export type ColumnResizingOptions = { 24 | handleWidth?: number; 25 | /** 26 | * Minimum width of a cell /column. The column cannot be resized smaller than this. 27 | */ 28 | cellMinWidth?: number; 29 | /** 30 | * The default minWidth of a cell / column when it doesn't have an explicit width (i.e.: it has not been resized manually) 31 | */ 32 | defaultCellMinWidth?: number; 33 | lastColumnResizable?: boolean; 34 | /** 35 | * A custom node view for the rendering table nodes. By default, the plugin 36 | * uses the {@link TableView} class. You can explicitly set this to `null` to 37 | * not use a custom node view. 38 | */ 39 | View?: 40 | | (new ( 41 | node: ProsemirrorNode, 42 | cellMinWidth: number, 43 | view: EditorView, 44 | ) => NodeView) 45 | | null; 46 | }; 47 | 48 | /** 49 | * @public 50 | */ 51 | export type Dragging = { startX: number; startWidth: number }; 52 | 53 | /** 54 | * @public 55 | */ 56 | export function columnResizing({ 57 | handleWidth = 5, 58 | cellMinWidth = 25, 59 | defaultCellMinWidth = 100, 60 | View = TableView, 61 | lastColumnResizable = true, 62 | }: ColumnResizingOptions = {}): Plugin { 63 | const plugin = new Plugin({ 64 | key: columnResizingPluginKey, 65 | state: { 66 | init(_, state) { 67 | const nodeViews = plugin.spec?.props?.nodeViews; 68 | const tableName = tableNodeTypes(state.schema).table.name; 69 | if (View && nodeViews) { 70 | nodeViews[tableName] = (node, view) => { 71 | return new View(node, defaultCellMinWidth, view); 72 | }; 73 | } 74 | return new ResizeState(-1, false); 75 | }, 76 | apply(tr, prev) { 77 | return prev.apply(tr); 78 | }, 79 | }, 80 | props: { 81 | attributes: (state): Record => { 82 | const pluginState = columnResizingPluginKey.getState(state); 83 | return pluginState && pluginState.activeHandle > -1 84 | ? { class: 'resize-cursor' } 85 | : {}; 86 | }, 87 | 88 | handleDOMEvents: { 89 | mousemove: (view, event) => { 90 | handleMouseMove(view, event, handleWidth, lastColumnResizable); 91 | }, 92 | mouseleave: (view) => { 93 | handleMouseLeave(view); 94 | }, 95 | mousedown: (view, event) => { 96 | handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth); 97 | }, 98 | }, 99 | 100 | decorations: (state) => { 101 | const pluginState = columnResizingPluginKey.getState(state); 102 | if (pluginState && pluginState.activeHandle > -1) { 103 | return handleDecorations(state, pluginState.activeHandle); 104 | } 105 | }, 106 | 107 | nodeViews: {}, 108 | }, 109 | }); 110 | return plugin; 111 | } 112 | 113 | /** 114 | * @public 115 | */ 116 | export class ResizeState { 117 | constructor( 118 | public activeHandle: number, 119 | public dragging: Dragging | false, 120 | ) {} 121 | 122 | apply(tr: Transaction): ResizeState { 123 | // eslint-disable-next-line @typescript-eslint/no-this-alias 124 | const state = this; 125 | const action = tr.getMeta(columnResizingPluginKey); 126 | if (action && action.setHandle != null) 127 | return new ResizeState(action.setHandle, false); 128 | if (action && action.setDragging !== undefined) 129 | return new ResizeState(state.activeHandle, action.setDragging); 130 | if (state.activeHandle > -1 && tr.docChanged) { 131 | let handle = tr.mapping.map(state.activeHandle, -1); 132 | if (!pointsAtCell(tr.doc.resolve(handle))) { 133 | handle = -1; 134 | } 135 | return new ResizeState(handle, state.dragging); 136 | } 137 | return state; 138 | } 139 | } 140 | 141 | function handleMouseMove( 142 | view: EditorView, 143 | event: MouseEvent, 144 | handleWidth: number, 145 | lastColumnResizable: boolean, 146 | ): void { 147 | if (!view.editable) return; 148 | 149 | const pluginState = columnResizingPluginKey.getState(view.state); 150 | if (!pluginState) return; 151 | 152 | if (!pluginState.dragging) { 153 | const target = domCellAround(event.target as HTMLElement); 154 | let cell = -1; 155 | if (target) { 156 | const { left, right } = target.getBoundingClientRect(); 157 | if (event.clientX - left <= handleWidth) 158 | cell = edgeCell(view, event, 'left', handleWidth); 159 | else if (right - event.clientX <= handleWidth) 160 | cell = edgeCell(view, event, 'right', handleWidth); 161 | } 162 | 163 | if (cell != pluginState.activeHandle) { 164 | if (!lastColumnResizable && cell !== -1) { 165 | const $cell = view.state.doc.resolve(cell); 166 | const table = $cell.node(-1); 167 | const map = TableMap.get(table); 168 | const tableStart = $cell.start(-1); 169 | const col = 170 | map.colCount($cell.pos - tableStart) + 171 | $cell.nodeAfter!.attrs.colspan - 172 | 1; 173 | 174 | if (col == map.width - 1) { 175 | return; 176 | } 177 | } 178 | 179 | updateHandle(view, cell); 180 | } 181 | } 182 | } 183 | 184 | function handleMouseLeave(view: EditorView): void { 185 | if (!view.editable) return; 186 | 187 | const pluginState = columnResizingPluginKey.getState(view.state); 188 | if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) 189 | updateHandle(view, -1); 190 | } 191 | 192 | function handleMouseDown( 193 | view: EditorView, 194 | event: MouseEvent, 195 | cellMinWidth: number, 196 | defaultCellMinWidth: number, 197 | ): boolean { 198 | if (!view.editable) return false; 199 | 200 | const win = view.dom.ownerDocument.defaultView ?? window; 201 | 202 | const pluginState = columnResizingPluginKey.getState(view.state); 203 | if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) 204 | return false; 205 | 206 | const cell = view.state.doc.nodeAt(pluginState.activeHandle)!; 207 | const width = currentColWidth(view, pluginState.activeHandle, cell.attrs); 208 | view.dispatch( 209 | view.state.tr.setMeta(columnResizingPluginKey, { 210 | setDragging: { startX: event.clientX, startWidth: width }, 211 | }), 212 | ); 213 | 214 | function finish(event: MouseEvent) { 215 | win.removeEventListener('mouseup', finish); 216 | win.removeEventListener('mousemove', move); 217 | const pluginState = columnResizingPluginKey.getState(view.state); 218 | if (pluginState?.dragging) { 219 | updateColumnWidth( 220 | view, 221 | pluginState.activeHandle, 222 | draggedWidth(pluginState.dragging, event, cellMinWidth), 223 | ); 224 | view.dispatch( 225 | view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }), 226 | ); 227 | } 228 | } 229 | 230 | function move(event: MouseEvent): void { 231 | if (!event.which) return finish(event); 232 | const pluginState = columnResizingPluginKey.getState(view.state); 233 | if (!pluginState) return; 234 | if (pluginState.dragging) { 235 | const dragged = draggedWidth(pluginState.dragging, event, cellMinWidth); 236 | displayColumnWidth( 237 | view, 238 | pluginState.activeHandle, 239 | dragged, 240 | defaultCellMinWidth, 241 | ); 242 | } 243 | } 244 | 245 | displayColumnWidth( 246 | view, 247 | pluginState.activeHandle, 248 | width, 249 | defaultCellMinWidth, 250 | ); 251 | 252 | win.addEventListener('mouseup', finish); 253 | win.addEventListener('mousemove', move); 254 | event.preventDefault(); 255 | return true; 256 | } 257 | 258 | function currentColWidth( 259 | view: EditorView, 260 | cellPos: number, 261 | { colspan, colwidth }: Attrs, 262 | ): number { 263 | const width = colwidth && colwidth[colwidth.length - 1]; 264 | if (width) return width; 265 | const dom = view.domAtPos(cellPos); 266 | const node = dom.node.childNodes[dom.offset] as HTMLElement; 267 | let domWidth = node.offsetWidth, 268 | parts = colspan; 269 | if (colwidth) 270 | for (let i = 0; i < colspan; i++) 271 | if (colwidth[i]) { 272 | domWidth -= colwidth[i]; 273 | parts--; 274 | } 275 | return domWidth / parts; 276 | } 277 | 278 | function domCellAround(target: HTMLElement | null): HTMLElement | null { 279 | while (target && target.nodeName != 'TD' && target.nodeName != 'TH') 280 | target = 281 | target.classList && target.classList.contains('ProseMirror') 282 | ? null 283 | : (target.parentNode as HTMLElement); 284 | return target; 285 | } 286 | 287 | function edgeCell( 288 | view: EditorView, 289 | event: MouseEvent, 290 | side: 'left' | 'right', 291 | handleWidth: number, 292 | ): number { 293 | // posAtCoords returns inconsistent positions when cursor is moving 294 | // across a collapsed table border. Use an offset to adjust the 295 | // target viewport coordinates away from the table border. 296 | const offset = side == 'right' ? -handleWidth : handleWidth; 297 | const found = view.posAtCoords({ 298 | left: event.clientX + offset, 299 | top: event.clientY, 300 | }); 301 | if (!found) return -1; 302 | const { pos } = found; 303 | const $cell = cellAround(view.state.doc.resolve(pos)); 304 | if (!$cell) return -1; 305 | if (side == 'right') return $cell.pos; 306 | const map = TableMap.get($cell.node(-1)), 307 | start = $cell.start(-1); 308 | const index = map.map.indexOf($cell.pos - start); 309 | return index % map.width == 0 ? -1 : start + map.map[index - 1]; 310 | } 311 | 312 | function draggedWidth( 313 | dragging: Dragging, 314 | event: MouseEvent, 315 | resizeMinWidth: number, 316 | ): number { 317 | const offset = event.clientX - dragging.startX; 318 | return Math.max(resizeMinWidth, dragging.startWidth + offset); 319 | } 320 | 321 | function updateHandle(view: EditorView, value: number): void { 322 | view.dispatch( 323 | view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }), 324 | ); 325 | } 326 | 327 | function updateColumnWidth( 328 | view: EditorView, 329 | cell: number, 330 | width: number, 331 | ): void { 332 | const $cell = view.state.doc.resolve(cell); 333 | const table = $cell.node(-1), 334 | map = TableMap.get(table), 335 | start = $cell.start(-1); 336 | const col = 337 | map.colCount($cell.pos - start) + $cell.nodeAfter!.attrs.colspan - 1; 338 | const tr = view.state.tr; 339 | for (let row = 0; row < map.height; row++) { 340 | const mapIndex = row * map.width + col; 341 | // Rowspanning cell that has already been handled 342 | if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue; 343 | const pos = map.map[mapIndex]; 344 | const attrs = table.nodeAt(pos)!.attrs as CellAttrs; 345 | const index = attrs.colspan == 1 ? 0 : col - map.colCount(pos); 346 | if (attrs.colwidth && attrs.colwidth[index] == width) continue; 347 | const colwidth = attrs.colwidth 348 | ? attrs.colwidth.slice() 349 | : zeroes(attrs.colspan); 350 | colwidth[index] = width; 351 | tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth: colwidth }); 352 | } 353 | if (tr.docChanged) view.dispatch(tr); 354 | } 355 | 356 | function displayColumnWidth( 357 | view: EditorView, 358 | cell: number, 359 | width: number, 360 | defaultCellMinWidth: number, 361 | ): void { 362 | const $cell = view.state.doc.resolve(cell); 363 | const table = $cell.node(-1), 364 | start = $cell.start(-1); 365 | const col = 366 | TableMap.get(table).colCount($cell.pos - start) + 367 | $cell.nodeAfter!.attrs.colspan - 368 | 1; 369 | let dom: Node | null = view.domAtPos($cell.start(-1)).node; 370 | while (dom && dom.nodeName != 'TABLE') { 371 | dom = dom.parentNode; 372 | } 373 | if (!dom) return; 374 | updateColumnsOnResize( 375 | table, 376 | dom.firstChild as HTMLTableColElement, 377 | dom as HTMLTableElement, 378 | defaultCellMinWidth, 379 | col, 380 | width, 381 | ); 382 | } 383 | 384 | function zeroes(n: number): 0[] { 385 | return Array(n).fill(0); 386 | } 387 | 388 | export function handleDecorations( 389 | state: EditorState, 390 | cell: number, 391 | ): DecorationSet { 392 | const decorations = []; 393 | const $cell = state.doc.resolve(cell); 394 | const table = $cell.node(-1); 395 | if (!table) { 396 | return DecorationSet.empty; 397 | } 398 | const map = TableMap.get(table); 399 | const start = $cell.start(-1); 400 | const col = 401 | map.colCount($cell.pos - start) + $cell.nodeAfter!.attrs.colspan - 1; 402 | for (let row = 0; row < map.height; row++) { 403 | const index = col + row * map.width; 404 | // For positions that have either a different cell or the end 405 | // of the table to their right, and either the top of the table or 406 | // a different cell above them, add a decoration 407 | if ( 408 | (col == map.width - 1 || map.map[index] != map.map[index + 1]) && 409 | (row == 0 || map.map[index] != map.map[index - map.width]) 410 | ) { 411 | const cellPos = map.map[index]; 412 | const pos = start + cellPos + table.nodeAt(cellPos)!.nodeSize - 1; 413 | const dom = document.createElement('div'); 414 | dom.className = 'column-resize-handle'; 415 | if (columnResizingPluginKey.getState(state)?.dragging) { 416 | decorations.push( 417 | Decoration.node( 418 | start + cellPos, 419 | start + cellPos + table.nodeAt(cellPos)!.nodeSize, 420 | { 421 | class: 'column-resize-dragging', 422 | }, 423 | ), 424 | ); 425 | } 426 | 427 | decorations.push(Decoration.widget(pos, dom)); 428 | } 429 | } 430 | return DecorationSet.create(state.doc, decorations); 431 | } 432 | -------------------------------------------------------------------------------- /src/cellselection.ts: -------------------------------------------------------------------------------- 1 | // This file defines a ProseMirror selection subclass that models 2 | // table cell selections. The table plugin needs to be active to wire 3 | // in the user interaction part of table selections (so that you 4 | // actually get such selections when you select across cells). 5 | 6 | import type { Node, ResolvedPos } from 'prosemirror-model'; 7 | import { Fragment, Slice } from 'prosemirror-model'; 8 | import type { EditorState, Transaction } from 'prosemirror-state'; 9 | import { 10 | NodeSelection, 11 | Selection, 12 | SelectionRange, 13 | TextSelection, 14 | } from 'prosemirror-state'; 15 | import type { Mappable } from 'prosemirror-transform'; 16 | import type { DecorationSource } from 'prosemirror-view'; 17 | import { Decoration, DecorationSet } from 'prosemirror-view'; 18 | 19 | import { TableMap } from './tablemap'; 20 | import type { CellAttrs } from './util'; 21 | import { inSameTable, pointsAtCell, removeColSpan } from './util'; 22 | 23 | /** 24 | * @public 25 | */ 26 | export interface CellSelectionJSON { 27 | type: string; 28 | anchor: number; 29 | head: number; 30 | } 31 | 32 | /** 33 | * A [`Selection`](http://prosemirror.net/docs/ref/#state.Selection) 34 | * subclass that represents a cell selection spanning part of a table. 35 | * With the plugin enabled, these will be created when the user 36 | * selects across cells, and will be drawn by giving selected cells a 37 | * `selectedCell` CSS class. 38 | * 39 | * @public 40 | */ 41 | export class CellSelection extends Selection { 42 | // A resolved position pointing _in front of_ the anchor cell (the one 43 | // that doesn't move when extending the selection). 44 | public $anchorCell: ResolvedPos; 45 | 46 | // A resolved position pointing in front of the head cell (the one 47 | // moves when extending the selection). 48 | public $headCell: ResolvedPos; 49 | 50 | // A table selection is identified by its anchor and head cells. The 51 | // positions given to this constructor should point _before_ two 52 | // cells in the same table. They may be the same, to select a single 53 | // cell. 54 | constructor($anchorCell: ResolvedPos, $headCell: ResolvedPos = $anchorCell) { 55 | const table = $anchorCell.node(-1); 56 | const map = TableMap.get(table); 57 | const tableStart = $anchorCell.start(-1); 58 | const rect = map.rectBetween( 59 | $anchorCell.pos - tableStart, 60 | $headCell.pos - tableStart, 61 | ); 62 | 63 | const doc = $anchorCell.node(0); 64 | const cells = map 65 | .cellsInRect(rect) 66 | .filter((p) => p != $headCell.pos - tableStart); 67 | // Make the head cell the first range, so that it counts as the 68 | // primary part of the selection 69 | cells.unshift($headCell.pos - tableStart); 70 | const ranges = cells.map((pos) => { 71 | const cell = table.nodeAt(pos); 72 | if (!cell) { 73 | throw new RangeError(`No cell with offset ${pos} found`); 74 | } 75 | const from = tableStart + pos + 1; 76 | return new SelectionRange( 77 | doc.resolve(from), 78 | doc.resolve(from + cell.content.size), 79 | ); 80 | }); 81 | super(ranges[0].$from, ranges[0].$to, ranges); 82 | this.$anchorCell = $anchorCell; 83 | this.$headCell = $headCell; 84 | } 85 | 86 | public map(doc: Node, mapping: Mappable): CellSelection | Selection { 87 | const $anchorCell = doc.resolve(mapping.map(this.$anchorCell.pos)); 88 | const $headCell = doc.resolve(mapping.map(this.$headCell.pos)); 89 | if ( 90 | pointsAtCell($anchorCell) && 91 | pointsAtCell($headCell) && 92 | inSameTable($anchorCell, $headCell) 93 | ) { 94 | const tableChanged = this.$anchorCell.node(-1) != $anchorCell.node(-1); 95 | if (tableChanged && this.isRowSelection()) 96 | return CellSelection.rowSelection($anchorCell, $headCell); 97 | else if (tableChanged && this.isColSelection()) 98 | return CellSelection.colSelection($anchorCell, $headCell); 99 | else return new CellSelection($anchorCell, $headCell); 100 | } 101 | return TextSelection.between($anchorCell, $headCell); 102 | } 103 | 104 | // Returns a rectangular slice of table rows containing the selected 105 | // cells. 106 | public override content(): Slice { 107 | const table = this.$anchorCell.node(-1); 108 | const map = TableMap.get(table); 109 | const tableStart = this.$anchorCell.start(-1); 110 | 111 | const rect = map.rectBetween( 112 | this.$anchorCell.pos - tableStart, 113 | this.$headCell.pos - tableStart, 114 | ); 115 | const seen: Record = {}; 116 | const rows = []; 117 | for (let row = rect.top; row < rect.bottom; row++) { 118 | const rowContent = []; 119 | for ( 120 | let index = row * map.width + rect.left, col = rect.left; 121 | col < rect.right; 122 | col++, index++ 123 | ) { 124 | const pos = map.map[index]; 125 | if (seen[pos]) continue; 126 | seen[pos] = true; 127 | 128 | const cellRect = map.findCell(pos); 129 | let cell = table.nodeAt(pos); 130 | if (!cell) { 131 | throw new RangeError(`No cell with offset ${pos} found`); 132 | } 133 | 134 | const extraLeft = rect.left - cellRect.left; 135 | const extraRight = cellRect.right - rect.right; 136 | 137 | if (extraLeft > 0 || extraRight > 0) { 138 | let attrs = cell.attrs as CellAttrs; 139 | if (extraLeft > 0) { 140 | attrs = removeColSpan(attrs, 0, extraLeft); 141 | } 142 | if (extraRight > 0) { 143 | attrs = removeColSpan( 144 | attrs, 145 | attrs.colspan - extraRight, 146 | extraRight, 147 | ); 148 | } 149 | if (cellRect.left < rect.left) { 150 | cell = cell.type.createAndFill(attrs); 151 | if (!cell) { 152 | throw new RangeError( 153 | `Could not create cell with attrs ${JSON.stringify(attrs)}`, 154 | ); 155 | } 156 | } else { 157 | cell = cell.type.create(attrs, cell.content); 158 | } 159 | } 160 | if (cellRect.top < rect.top || cellRect.bottom > rect.bottom) { 161 | const attrs = { 162 | ...cell.attrs, 163 | rowspan: 164 | Math.min(cellRect.bottom, rect.bottom) - 165 | Math.max(cellRect.top, rect.top), 166 | }; 167 | if (cellRect.top < rect.top) { 168 | cell = cell.type.createAndFill(attrs)!; 169 | } else { 170 | cell = cell.type.create(attrs, cell.content); 171 | } 172 | } 173 | rowContent.push(cell); 174 | } 175 | rows.push(table.child(row).copy(Fragment.from(rowContent))); 176 | } 177 | 178 | const fragment = 179 | this.isColSelection() && this.isRowSelection() ? table : rows; 180 | return new Slice(Fragment.from(fragment), 1, 1); 181 | } 182 | 183 | public override replace(tr: Transaction, content: Slice = Slice.empty): void { 184 | const mapFrom = tr.steps.length, 185 | ranges = this.ranges; 186 | for (let i = 0; i < ranges.length; i++) { 187 | const { $from, $to } = ranges[i], 188 | mapping = tr.mapping.slice(mapFrom); 189 | tr.replace( 190 | mapping.map($from.pos), 191 | mapping.map($to.pos), 192 | i ? Slice.empty : content, 193 | ); 194 | } 195 | const sel = Selection.findFrom( 196 | tr.doc.resolve(tr.mapping.slice(mapFrom).map(this.to)), 197 | -1, 198 | ); 199 | if (sel) tr.setSelection(sel); 200 | } 201 | 202 | public override replaceWith(tr: Transaction, node: Node): void { 203 | this.replace(tr, new Slice(Fragment.from(node), 0, 0)); 204 | } 205 | 206 | public forEachCell(f: (node: Node, pos: number) => void): void { 207 | const table = this.$anchorCell.node(-1); 208 | const map = TableMap.get(table); 209 | const tableStart = this.$anchorCell.start(-1); 210 | 211 | const cells = map.cellsInRect( 212 | map.rectBetween( 213 | this.$anchorCell.pos - tableStart, 214 | this.$headCell.pos - tableStart, 215 | ), 216 | ); 217 | for (let i = 0; i < cells.length; i++) { 218 | f(table.nodeAt(cells[i])!, tableStart + cells[i]); 219 | } 220 | } 221 | 222 | // True if this selection goes all the way from the top to the 223 | // bottom of the table. 224 | public isColSelection(): boolean { 225 | const anchorTop = this.$anchorCell.index(-1); 226 | const headTop = this.$headCell.index(-1); 227 | if (Math.min(anchorTop, headTop) > 0) return false; 228 | 229 | const anchorBottom = anchorTop + this.$anchorCell.nodeAfter!.attrs.rowspan; 230 | const headBottom = headTop + this.$headCell.nodeAfter!.attrs.rowspan; 231 | 232 | return ( 233 | Math.max(anchorBottom, headBottom) == this.$headCell.node(-1).childCount 234 | ); 235 | } 236 | 237 | // Returns the smallest column selection that covers the given anchor 238 | // and head cell. 239 | public static colSelection( 240 | $anchorCell: ResolvedPos, 241 | $headCell: ResolvedPos = $anchorCell, 242 | ): CellSelection { 243 | const table = $anchorCell.node(-1); 244 | const map = TableMap.get(table); 245 | const tableStart = $anchorCell.start(-1); 246 | 247 | const anchorRect = map.findCell($anchorCell.pos - tableStart); 248 | const headRect = map.findCell($headCell.pos - tableStart); 249 | const doc = $anchorCell.node(0); 250 | 251 | if (anchorRect.top <= headRect.top) { 252 | if (anchorRect.top > 0) 253 | $anchorCell = doc.resolve(tableStart + map.map[anchorRect.left]); 254 | if (headRect.bottom < map.height) 255 | $headCell = doc.resolve( 256 | tableStart + 257 | map.map[map.width * (map.height - 1) + headRect.right - 1], 258 | ); 259 | } else { 260 | if (headRect.top > 0) 261 | $headCell = doc.resolve(tableStart + map.map[headRect.left]); 262 | if (anchorRect.bottom < map.height) 263 | $anchorCell = doc.resolve( 264 | tableStart + 265 | map.map[map.width * (map.height - 1) + anchorRect.right - 1], 266 | ); 267 | } 268 | return new CellSelection($anchorCell, $headCell); 269 | } 270 | 271 | // True if this selection goes all the way from the left to the 272 | // right of the table. 273 | public isRowSelection(): boolean { 274 | const table = this.$anchorCell.node(-1); 275 | const map = TableMap.get(table); 276 | const tableStart = this.$anchorCell.start(-1); 277 | 278 | const anchorLeft = map.colCount(this.$anchorCell.pos - tableStart); 279 | const headLeft = map.colCount(this.$headCell.pos - tableStart); 280 | if (Math.min(anchorLeft, headLeft) > 0) return false; 281 | 282 | const anchorRight = anchorLeft + this.$anchorCell.nodeAfter!.attrs.colspan; 283 | const headRight = headLeft + this.$headCell.nodeAfter!.attrs.colspan; 284 | return Math.max(anchorRight, headRight) == map.width; 285 | } 286 | 287 | public eq(other: unknown): boolean { 288 | return ( 289 | other instanceof CellSelection && 290 | other.$anchorCell.pos == this.$anchorCell.pos && 291 | other.$headCell.pos == this.$headCell.pos 292 | ); 293 | } 294 | 295 | // Returns the smallest row selection that covers the given anchor 296 | // and head cell. 297 | public static rowSelection( 298 | $anchorCell: ResolvedPos, 299 | $headCell: ResolvedPos = $anchorCell, 300 | ): CellSelection { 301 | const table = $anchorCell.node(-1); 302 | const map = TableMap.get(table); 303 | const tableStart = $anchorCell.start(-1); 304 | 305 | const anchorRect = map.findCell($anchorCell.pos - tableStart); 306 | const headRect = map.findCell($headCell.pos - tableStart); 307 | const doc = $anchorCell.node(0); 308 | 309 | if (anchorRect.left <= headRect.left) { 310 | if (anchorRect.left > 0) 311 | $anchorCell = doc.resolve( 312 | tableStart + map.map[anchorRect.top * map.width], 313 | ); 314 | if (headRect.right < map.width) 315 | $headCell = doc.resolve( 316 | tableStart + map.map[map.width * (headRect.top + 1) - 1], 317 | ); 318 | } else { 319 | if (headRect.left > 0) 320 | $headCell = doc.resolve(tableStart + map.map[headRect.top * map.width]); 321 | if (anchorRect.right < map.width) 322 | $anchorCell = doc.resolve( 323 | tableStart + map.map[map.width * (anchorRect.top + 1) - 1], 324 | ); 325 | } 326 | return new CellSelection($anchorCell, $headCell); 327 | } 328 | 329 | public toJSON(): CellSelectionJSON { 330 | return { 331 | type: 'cell', 332 | anchor: this.$anchorCell.pos, 333 | head: this.$headCell.pos, 334 | }; 335 | } 336 | 337 | public static override fromJSON( 338 | doc: Node, 339 | json: CellSelectionJSON, 340 | ): CellSelection { 341 | return new CellSelection(doc.resolve(json.anchor), doc.resolve(json.head)); 342 | } 343 | 344 | static create( 345 | doc: Node, 346 | anchorCell: number, 347 | headCell: number = anchorCell, 348 | ): CellSelection { 349 | return new CellSelection(doc.resolve(anchorCell), doc.resolve(headCell)); 350 | } 351 | 352 | public override getBookmark(): CellBookmark { 353 | return new CellBookmark(this.$anchorCell.pos, this.$headCell.pos); 354 | } 355 | } 356 | 357 | CellSelection.prototype.visible = false; 358 | 359 | Selection.jsonID('cell', CellSelection); 360 | 361 | /** 362 | * @public 363 | */ 364 | export class CellBookmark { 365 | constructor( 366 | public anchor: number, 367 | public head: number, 368 | ) {} 369 | 370 | map(mapping: Mappable): CellBookmark { 371 | return new CellBookmark(mapping.map(this.anchor), mapping.map(this.head)); 372 | } 373 | 374 | resolve(doc: Node): CellSelection | Selection { 375 | const $anchorCell = doc.resolve(this.anchor), 376 | $headCell = doc.resolve(this.head); 377 | if ( 378 | $anchorCell.parent.type.spec.tableRole == 'row' && 379 | $headCell.parent.type.spec.tableRole == 'row' && 380 | $anchorCell.index() < $anchorCell.parent.childCount && 381 | $headCell.index() < $headCell.parent.childCount && 382 | inSameTable($anchorCell, $headCell) 383 | ) 384 | return new CellSelection($anchorCell, $headCell); 385 | else return Selection.near($headCell, 1); 386 | } 387 | } 388 | 389 | export function drawCellSelection(state: EditorState): DecorationSource | null { 390 | if (!(state.selection instanceof CellSelection)) return null; 391 | const cells: Decoration[] = []; 392 | state.selection.forEachCell((node, pos) => { 393 | cells.push( 394 | Decoration.node(pos, pos + node.nodeSize, { class: 'selectedCell' }), 395 | ); 396 | }); 397 | return DecorationSet.create(state.doc, cells); 398 | } 399 | 400 | function isCellBoundarySelection({ $from, $to }: TextSelection) { 401 | if ($from.pos == $to.pos || $from.pos < $to.pos - 6) return false; // Cheap elimination 402 | let afterFrom = $from.pos; 403 | let beforeTo = $to.pos; 404 | let depth = $from.depth; 405 | for (; depth >= 0; depth--, afterFrom++) 406 | if ($from.after(depth + 1) < $from.end(depth)) break; 407 | for (let d = $to.depth; d >= 0; d--, beforeTo--) 408 | if ($to.before(d + 1) > $to.start(d)) break; 409 | return ( 410 | afterFrom == beforeTo && 411 | /row|table/.test($from.node(depth).type.spec.tableRole) 412 | ); 413 | } 414 | 415 | function isTextSelectionAcrossCells({ $from, $to }: TextSelection) { 416 | let fromCellBoundaryNode: Node | undefined; 417 | let toCellBoundaryNode: Node | undefined; 418 | 419 | for (let i = $from.depth; i > 0; i--) { 420 | const node = $from.node(i); 421 | if ( 422 | node.type.spec.tableRole === 'cell' || 423 | node.type.spec.tableRole === 'header_cell' 424 | ) { 425 | fromCellBoundaryNode = node; 426 | break; 427 | } 428 | } 429 | 430 | for (let i = $to.depth; i > 0; i--) { 431 | const node = $to.node(i); 432 | if ( 433 | node.type.spec.tableRole === 'cell' || 434 | node.type.spec.tableRole === 'header_cell' 435 | ) { 436 | toCellBoundaryNode = node; 437 | break; 438 | } 439 | } 440 | 441 | return fromCellBoundaryNode !== toCellBoundaryNode && $to.parentOffset === 0; 442 | } 443 | 444 | export function normalizeSelection( 445 | state: EditorState, 446 | tr: Transaction | undefined, 447 | allowTableNodeSelection: boolean, 448 | ): Transaction | undefined { 449 | const sel = (tr || state).selection; 450 | const doc = (tr || state).doc; 451 | let normalize: Selection | undefined; 452 | let role: string | undefined; 453 | if (sel instanceof NodeSelection && (role = sel.node.type.spec.tableRole)) { 454 | if (role == 'cell' || role == 'header_cell') { 455 | normalize = CellSelection.create(doc, sel.from); 456 | } else if (role == 'row') { 457 | const $cell = doc.resolve(sel.from + 1); 458 | normalize = CellSelection.rowSelection($cell, $cell); 459 | } else if (!allowTableNodeSelection) { 460 | const map = TableMap.get(sel.node); 461 | const start = sel.from + 1; 462 | const lastCell = start + map.map[map.width * map.height - 1]; 463 | normalize = CellSelection.create(doc, start + 1, lastCell); 464 | } 465 | } else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) { 466 | normalize = TextSelection.create(doc, sel.from); 467 | } else if (sel instanceof TextSelection && isTextSelectionAcrossCells(sel)) { 468 | normalize = TextSelection.create(doc, sel.$from.start(), sel.$from.end()); 469 | } 470 | if (normalize) (tr || (tr = state.tr)).setSelection(normalize); 471 | return tr; 472 | } 473 | -------------------------------------------------------------------------------- /test/commands.test.ts: -------------------------------------------------------------------------------- 1 | import ist from 'ist'; 2 | import type { Node } from 'prosemirror-model'; 3 | import type { Command, Transaction } from 'prosemirror-state'; 4 | import { EditorState } from 'prosemirror-state'; 5 | import { describe, it } from 'vitest'; 6 | 7 | import { 8 | addColumnAfter, 9 | addColumnBefore, 10 | deleteColumn, 11 | addRowAfter, 12 | addRowBefore, 13 | deleteRow, 14 | mergeCells, 15 | splitCell, 16 | splitCellWithType, 17 | setCellAttr, 18 | toggleHeader, 19 | toggleHeaderRow, 20 | toggleHeaderColumn, 21 | } from '../src'; 22 | 23 | import type { TaggedNode } from './build'; 24 | import { 25 | doc, 26 | table, 27 | tr, 28 | p, 29 | td, 30 | th, 31 | c, 32 | h, 33 | c11, 34 | h11, 35 | cEmpty, 36 | hEmpty, 37 | cCursor, 38 | hCursor, 39 | cHead, 40 | cAnchor, 41 | eq, 42 | selectionFor, 43 | } from './build'; 44 | 45 | function test( 46 | doc: TaggedNode, 47 | command: Command, 48 | result: Node | null | undefined, 49 | ) { 50 | let state = EditorState.create({ doc, selection: selectionFor(doc) }); 51 | const ran = command(state, (tr) => (state = state.apply(tr))); 52 | if (result == null) ist(ran, false); 53 | else ist(state.doc, result, eq); 54 | } 55 | 56 | describe('addColumnAfter', () => { 57 | it('can add a plain column', () => 58 | test( 59 | table(tr(c11, c11, c11), tr(c11, cCursor, c11), tr(c11, c11, c11)), 60 | addColumnAfter, 61 | table( 62 | tr(c11, c11, cEmpty, c11), 63 | tr(c11, c11, cEmpty, c11), 64 | tr(c11, c11, cEmpty, c11), 65 | ), 66 | )); 67 | 68 | it('can add a column at the right of the table', () => 69 | test( 70 | table(tr(c11, c11, c11), tr(c11, c11, c11), tr(c11, c11, cCursor)), 71 | addColumnAfter, 72 | table( 73 | tr(c11, c11, c11, cEmpty), 74 | tr(c11, c11, c11, cEmpty), 75 | tr(c11, c11, c11, cEmpty), 76 | ), 77 | )); 78 | 79 | it('can add a second cell', () => 80 | test(table(tr(cCursor)), addColumnAfter, table(tr(c11, cEmpty)))); 81 | 82 | it('can grow a colspan cell', () => 83 | test( 84 | table(tr(cCursor, c11), tr(c(2, 1))), 85 | addColumnAfter, 86 | table(tr(c11, cEmpty, c11), tr(c(3, 1))), 87 | )); 88 | 89 | it("places new cells in the right spot when there's row spans", () => 90 | test( 91 | table(tr(c11, c(1, 2), c(1, 2)), tr(c11), tr(c11, cCursor, c11)), 92 | addColumnAfter, 93 | table( 94 | tr(c11, c(1, 2), cEmpty, c(1, 2)), 95 | tr(c11, cEmpty), 96 | tr(c11, c11, cEmpty, c11), 97 | ), 98 | )); 99 | 100 | it('can place new cells into an empty row', () => 101 | test( 102 | table(tr(c(1, 2), c(1, 2)), tr(), tr(cCursor, c11)), 103 | addColumnAfter, 104 | table(tr(c(1, 2), cEmpty, c(1, 2)), tr(cEmpty), tr(c11, cEmpty, c11)), 105 | )); 106 | 107 | it('will skip ahead when growing a rowspan cell', () => 108 | test( 109 | table(tr(c(2, 2), c11), tr(c11), tr(cCursor, c11, c11)), 110 | addColumnAfter, 111 | table(tr(c(3, 2), c11), tr(c11), tr(cCursor, cEmpty, c11, c11)), 112 | )); 113 | 114 | it('will use the right side of a single cell selection', () => 115 | test( 116 | table(tr(cAnchor, c11), tr(c11, c11)), 117 | addColumnAfter, 118 | table(tr(c11, cEmpty, c11), tr(c11, cEmpty, c11)), 119 | )); 120 | 121 | it('will use the right side of a bigger cell selection', () => 122 | test( 123 | table(tr(cHead, c11, c11), tr(c11, cAnchor, c11)), 124 | addColumnAfter, 125 | table(tr(c11, c11, cEmpty, c11), tr(c11, c11, cEmpty, c11)), 126 | )); 127 | 128 | it('properly handles a cell node selection', () => 129 | test( 130 | table(tr('', c11, c11), tr(c11, c11)), 131 | addColumnAfter, 132 | table(tr(c11, cEmpty, c11), tr(c11, cEmpty, c11)), 133 | )); 134 | 135 | it('preserves header rows', () => 136 | test( 137 | table(tr(h11, h11), tr(c11, cCursor)), 138 | addColumnAfter, 139 | table(tr(h11, h11, hEmpty), tr(c11, c11, cEmpty)), 140 | )); 141 | 142 | it('uses column after as reference when header column before', () => 143 | test( 144 | table(tr(h11, h11), tr(hCursor, c11)), 145 | addColumnAfter, 146 | table(tr(h11, hEmpty, h11), tr(h11, cEmpty, c11)), 147 | )); 148 | 149 | it('creates regular cells when only next to a header column', () => 150 | test( 151 | table(tr(c11, h11), tr(c11, hCursor)), 152 | addColumnAfter, 153 | table(tr(c11, h11, cEmpty), tr(c11, h11, cEmpty)), 154 | )); 155 | 156 | it('does nothing outside of a table', () => 157 | test(doc(p('foo')), addColumnAfter, null)); 158 | 159 | it('preserves column widths', () => 160 | test( 161 | table( 162 | tr(cAnchor, c11), 163 | tr(td({ colspan: 2, colwidth: [100, 200] }, p('a'))), 164 | ), 165 | addColumnAfter, 166 | table( 167 | tr(cAnchor, cEmpty, c11), 168 | tr(td({ colspan: 3, colwidth: [100, 0, 200] }, p('a'))), 169 | ), 170 | )); 171 | }); 172 | 173 | describe('addColumnBefore', () => { 174 | it('can add a plain column', () => 175 | test( 176 | table(tr(c11, c11, c11), tr(c11, cCursor, c11), tr(c11, c11, c11)), 177 | addColumnBefore, 178 | table( 179 | tr(c11, cEmpty, c11, c11), 180 | tr(c11, cEmpty, c11, c11), 181 | tr(c11, cEmpty, c11, c11), 182 | ), 183 | )); 184 | 185 | it('can add a column at the left of the table', () => 186 | test( 187 | table(tr(cCursor, c11, c11), tr(c11, c11, c11), tr(c11, c11, c11)), 188 | addColumnBefore, 189 | table( 190 | tr(cEmpty, c11, c11, c11), 191 | tr(cEmpty, c11, c11, c11), 192 | tr(cEmpty, c11, c11, c11), 193 | ), 194 | )); 195 | 196 | it('will use the left side of a single cell selection', () => 197 | test( 198 | table(tr(cAnchor, c11), tr(c11, c11)), 199 | addColumnBefore, 200 | table(tr(cEmpty, c11, c11), tr(cEmpty, c11, c11)), 201 | )); 202 | 203 | it('will use the left side of a bigger cell selection', () => 204 | test( 205 | table(tr(c11, cHead, c11), tr(c11, c11, cAnchor)), 206 | addColumnBefore, 207 | table(tr(c11, cEmpty, c11, c11), tr(c11, cEmpty, c11, c11)), 208 | )); 209 | 210 | it('preserves header rows', () => 211 | test( 212 | table(tr(h11, h11), tr(cCursor, c11)), 213 | addColumnBefore, 214 | table(tr(hEmpty, h11, h11), tr(cEmpty, c11, c11)), 215 | )); 216 | }); 217 | 218 | describe('deleteColumn', () => { 219 | it('can delete a plain column', () => 220 | test( 221 | table(tr(cEmpty, c11, c11), tr(c11, cCursor, c11), tr(c11, c11, cEmpty)), 222 | deleteColumn, 223 | table(tr(cEmpty, c11), tr(c11, c11), tr(c11, cEmpty)), 224 | )); 225 | 226 | it('can delete the first column', () => 227 | test( 228 | table(tr(cCursor, cEmpty, c11), tr(c11, c11, c11), tr(c11, c11, c11)), 229 | deleteColumn, 230 | table(tr(cEmpty, c11), tr(c11, c11), tr(c11, c11)), 231 | )); 232 | 233 | it('can delete the last column', () => 234 | test( 235 | table(tr(c11, cEmpty, cCursor), tr(c11, c11, c11), tr(c11, c11, c11)), 236 | deleteColumn, 237 | table(tr(c11, cEmpty), tr(c11, c11), tr(c11, c11)), 238 | )); 239 | 240 | it("can reduce a cell's colspan", () => 241 | test( 242 | table(tr(c11, cCursor), tr(c(2, 1))), 243 | deleteColumn, 244 | table(tr(c11), tr(c11)), 245 | )); 246 | 247 | it('will skip rows after a rowspan', () => 248 | test( 249 | table(tr(c11, cCursor), tr(c11, c(1, 2)), tr(c11)), 250 | deleteColumn, 251 | table(tr(c11), tr(c11), tr(c11)), 252 | )); 253 | 254 | it('will delete all columns under a colspan cell', () => 255 | test( 256 | table(tr(c11, td({ colspan: 2 }, p(''))), tr(cEmpty, c11, c11)), 257 | deleteColumn, 258 | table(tr(c11), tr(cEmpty)), 259 | )); 260 | 261 | it('deletes a cell-selected column', () => 262 | test( 263 | table(tr(cEmpty, cAnchor), tr(c11, cHead)), 264 | deleteColumn, 265 | table(tr(cEmpty), tr(c11)), 266 | )); 267 | 268 | it('deletes multiple cell-selected columns', () => 269 | test( 270 | table(tr(c(1, 2), cAnchor, c11), tr(c11, cEmpty), tr(cHead, c11, c11)), 271 | deleteColumn, 272 | table(tr(c11), tr(cEmpty), tr(c11)), 273 | )); 274 | 275 | it('leaves column widths intact', () => 276 | test( 277 | table( 278 | tr(c11, cAnchor, c11), 279 | tr(td({ colspan: 3, colwidth: [100, 200, 300] }, p('y'))), 280 | ), 281 | deleteColumn, 282 | table(tr(c11, c11), tr(td({ colspan: 2, colwidth: [100, 300] }, p('y')))), 283 | )); 284 | 285 | it('resets column width when all zeroes', () => 286 | test( 287 | table( 288 | tr(c11, cAnchor, c11), 289 | tr(td({ colspan: 3, colwidth: [0, 200, 0] }, p('y'))), 290 | ), 291 | deleteColumn, 292 | table(tr(c11, c11), tr(td({ colspan: 2 }, p('y')))), 293 | )); 294 | }); 295 | 296 | describe('addRowAfter', () => { 297 | it('can add a simple row', () => 298 | test( 299 | table(tr(cCursor, c11), tr(c11, c11)), 300 | addRowAfter, 301 | table(tr(c11, c11), tr(cEmpty, cEmpty), tr(c11, c11)), 302 | )); 303 | 304 | it('can add a row at the end', () => 305 | test( 306 | table(tr(c11, c11), tr(c11, cCursor)), 307 | addRowAfter, 308 | table(tr(c11, c11), tr(c11, c11), tr(cEmpty, cEmpty)), 309 | )); 310 | 311 | it('increases rowspan when needed', () => 312 | test( 313 | table(tr(cCursor, c(1, 2)), tr(c11)), 314 | addRowAfter, 315 | table(tr(c11, c(1, 3)), tr(cEmpty), tr(c11)), 316 | )); 317 | 318 | it('skips columns for colspan cells', () => 319 | test( 320 | table(tr(cCursor, c(2, 2)), tr(c11)), 321 | addRowAfter, 322 | table(tr(c11, c(2, 3)), tr(cEmpty), tr(c11)), 323 | )); 324 | 325 | it('picks the row after a cell selection', () => 326 | test( 327 | table(tr(cHead, c11, c11), tr(c11, cAnchor, c11), tr(c(3, 1))), 328 | addRowAfter, 329 | table( 330 | tr(c11, c11, c11), 331 | tr(c11, c11, c11), 332 | tr(cEmpty, cEmpty, cEmpty), 333 | tr(c(3, 1)), 334 | ), 335 | )); 336 | 337 | it('preserves header columns', () => 338 | test( 339 | table(tr(c11, hCursor), tr(c11, h11)), 340 | addRowAfter, 341 | table(tr(c11, h11), tr(cEmpty, hEmpty), tr(c11, h11)), 342 | )); 343 | 344 | it('uses next row as reference when row before is a header', () => 345 | test( 346 | table(tr(h11, hCursor), tr(c11, h11)), 347 | addRowAfter, 348 | table(tr(h11, h11), tr(cEmpty, hEmpty), tr(c11, h11)), 349 | )); 350 | 351 | it('creates regular cells when no reference row is available', () => 352 | test( 353 | table(tr(h11, hCursor)), 354 | addRowAfter, 355 | table(tr(h11, h11), tr(cEmpty, cEmpty)), 356 | )); 357 | }); 358 | 359 | describe('addRowBefore', () => { 360 | it('can add a simple row', () => 361 | test( 362 | table(tr(c11, c11), tr(cCursor, c11)), 363 | addRowBefore, 364 | table(tr(c11, c11), tr(cEmpty, cEmpty), tr(c11, c11)), 365 | )); 366 | 367 | it('can add a row at the start', () => 368 | test( 369 | table(tr(cCursor, c11), tr(c11, c11)), 370 | addRowBefore, 371 | table(tr(cEmpty, cEmpty), tr(c11, c11), tr(c11, c11)), 372 | )); 373 | 374 | it('picks the row before a cell selection', () => 375 | test( 376 | table(tr(c11, c(2, 1)), tr(cAnchor, c11, c11), tr(c11, cHead, c11)), 377 | addRowBefore, 378 | table( 379 | tr(c11, c(2, 1)), 380 | tr(cEmpty, cEmpty, cEmpty), 381 | tr(c11, c11, c11), 382 | tr(c11, c11, c11), 383 | ), 384 | )); 385 | 386 | it('preserves header columns', () => 387 | test( 388 | table(tr(hCursor, c11), tr(h11, c11)), 389 | addRowBefore, 390 | table(tr(hEmpty, cEmpty), tr(h11, c11), tr(h11, c11)), 391 | )); 392 | }); 393 | 394 | describe('deleteRow', () => { 395 | it('can delete a simple row', () => 396 | test( 397 | table(tr(c11, cEmpty), tr(cCursor, c11), tr(c11, cEmpty)), 398 | deleteRow, 399 | table(tr(c11, cEmpty), tr(c11, cEmpty)), 400 | )); 401 | 402 | it('can delete the first row', () => 403 | test( 404 | table(tr(c11, cCursor), tr(cEmpty, c11), tr(c11, cEmpty)), 405 | deleteRow, 406 | table(tr(cEmpty, c11), tr(c11, cEmpty)), 407 | )); 408 | 409 | it('can delete the last row', () => 410 | test( 411 | table(tr(cEmpty, c11), tr(c11, cEmpty), tr(c11, cCursor)), 412 | deleteRow, 413 | table(tr(cEmpty, c11), tr(c11, cEmpty)), 414 | )); 415 | 416 | it('can shrink rowspan cells', () => 417 | test( 418 | table(tr(c(1, 2), c11, c(1, 3)), tr(cCursor), tr(c11, c11)), 419 | deleteRow, 420 | table(tr(c11, c11, c(1, 2)), tr(c11, c11)), 421 | )); 422 | 423 | it('can move cells that start in the deleted row', () => { 424 | test( 425 | table(tr(c(1, 2), cCursor), tr(cEmpty)), 426 | deleteRow, 427 | table(tr(c11, cEmpty)), 428 | ); 429 | 430 | test( 431 | table( 432 | tr(td({ rowspan: 3 }, p('')), c11), 433 | tr(/* */ c11), 434 | tr(/* */ c(1, 3)), 435 | tr(c11 /* */), 436 | tr(c11 /* */), 437 | ), 438 | deleteRow, 439 | table( 440 | // 441 | tr(c11, c(1, 2)), 442 | tr(c11 /* */), 443 | ), 444 | ); 445 | }); 446 | 447 | it('moves the same cell with colspan greater than 1 that start in the deleted row only once', () => 448 | test( 449 | table(tr(c(3, 2), c11, c(2, 2), cCursor), tr(c11, cEmpty)), 450 | deleteRow, 451 | table(tr(c(3, 1), c11, c(2, 1), cEmpty)), 452 | )); 453 | 454 | it('deletes multiple rows when the start cell has a rowspan', () => 455 | test( 456 | table( 457 | tr(td({ rowspan: 3 }, p('')), c11), 458 | tr(c11), 459 | tr(c11), 460 | tr(c11, c11), 461 | ), 462 | deleteRow, 463 | table(tr(c11, c11)), 464 | )); 465 | 466 | it('moves the same cell with colspan greater than 1 that start in the deleted row only once when deleting multiple rows', () => 467 | test( 468 | table( 469 | tr(c(2, 4), td({ rowspan: 3 }, p('')), c11), 470 | tr(c11), 471 | tr(c11), 472 | tr(c11, c11), 473 | ), 474 | deleteRow, 475 | table(tr(c(2, 1), c11, c11)), 476 | )); 477 | 478 | it('skips columns when adjusting rowspan', () => 479 | test( 480 | table(tr(cCursor, c(2, 2)), tr(c11)), 481 | deleteRow, 482 | table(tr(c11, c(2, 1))), 483 | )); 484 | 485 | it('can delete a cell selection', () => 486 | test( 487 | table(tr(cAnchor, c11), tr(c11, cEmpty)), 488 | deleteRow, 489 | table(tr(c11, cEmpty)), 490 | )); 491 | 492 | it('will delete all rows in the cell selection', () => 493 | test( 494 | table(tr(c11, cEmpty), tr(cAnchor, c11), tr(c11, cHead), tr(cEmpty, c11)), 495 | deleteRow, 496 | table(tr(c11, cEmpty), tr(cEmpty, c11)), 497 | )); 498 | }); 499 | 500 | describe('mergeCells', () => { 501 | it("doesn't do anything when only one cell is selected", () => 502 | test(table(tr(cAnchor, c11)), mergeCells, null)); 503 | 504 | it("doesn't do anything when the selection cuts across spanning cells", () => 505 | test(table(tr(cAnchor, c(2, 1)), tr(c11, cHead, c11)), mergeCells, null)); 506 | 507 | it('can merge two cells in a column', () => 508 | test( 509 | table(tr(cAnchor, cHead, c11)), 510 | mergeCells, 511 | table(tr(td({ colspan: 2 }, p('x'), p('x')), c11)), 512 | )); 513 | 514 | it('can merge two cells in a row', () => 515 | test( 516 | table(tr(cAnchor, c11), tr(cHead, c11)), 517 | mergeCells, 518 | table(tr(td({ rowspan: 2 }, p('x'), p('x')), c11), tr(c11)), 519 | )); 520 | 521 | it('can merge a rectangle of cells', () => 522 | test( 523 | table( 524 | tr(c11, cAnchor, cEmpty, cEmpty, c11), 525 | tr(c11, cEmpty, cEmpty, cHead, c11), 526 | ), 527 | mergeCells, 528 | table( 529 | tr(c11, td({ rowspan: 2, colspan: 3 }, p('x'), p('x')), c11), 530 | tr(c11, c11), 531 | ), 532 | )); 533 | 534 | it('can merge already spanning cells', () => 535 | test( 536 | table( 537 | tr(c11, cAnchor, c(1, 2), cEmpty, c11), 538 | tr(c11, cEmpty, cHead, c11), 539 | ), 540 | mergeCells, 541 | table( 542 | tr(c11, td({ rowspan: 2, colspan: 3 }, p('x'), p('x'), p('x')), c11), 543 | tr(c11, c11), 544 | ), 545 | )); 546 | 547 | it('keeps the column width of the first col', () => 548 | test( 549 | table(tr(td({ colwidth: [100] }, p('x')), c11), tr(c11, cHead)), 550 | mergeCells, 551 | table( 552 | tr( 553 | td( 554 | { colspan: 2, rowspan: 2, colwidth: [100, 0] }, 555 | p('x'), 556 | p('x'), 557 | p('x'), 558 | p('x'), 559 | ), 560 | ), 561 | tr(), 562 | ), 563 | )); 564 | }); 565 | 566 | describe('splitCell', () => { 567 | it('does nothing when cursor is inside of a cell with attributes colspan = 1 and rowspan = 1', () => 568 | test(table(tr(cCursor, c11)), splitCell, null)); 569 | 570 | it('can split when col-spanning cell with cursor', () => 571 | test( 572 | table(tr(td({ colspan: 2 }, p('foo')), c11)), 573 | splitCell, 574 | table(tr(td(p('foo')), cEmpty, c11)), 575 | )); 576 | 577 | it('can split when col-spanning header-cell with cursor', () => 578 | test( 579 | table(tr(th({ colspan: 2 }, p('foo')))), 580 | splitCell, 581 | table(tr(th(p('foo')), hEmpty)), 582 | )); 583 | 584 | it('does nothing for a multi-cell selection', () => 585 | test(table(tr(cAnchor, cHead, c11)), splitCell, null)); 586 | 587 | it("does nothing when the selected cell doesn't span anything", () => 588 | test(table(tr(cAnchor, c11)), splitCell, null)); 589 | 590 | it('can split a col-spanning cell', () => 591 | test( 592 | table(tr(td({ colspan: 2 }, p('foo')), c11)), 593 | splitCell, 594 | table(tr(td(p('foo')), cEmpty, c11)), 595 | )); 596 | 597 | it('can split a row-spanning cell', () => 598 | test( 599 | table(tr(c11, td({ rowspan: 2 }, p('foo')), c11), tr(c11, c11)), 600 | splitCell, 601 | table(tr(c11, td(p('foo')), c11), tr(c11, cEmpty, c11)), 602 | )); 603 | 604 | it('can split a rectangular cell', () => 605 | test( 606 | table( 607 | tr(c(4, 1)), 608 | tr(c11, td({ rowspan: 2, colspan: 2 }, p('foo')), c11), 609 | tr(c11, c11), 610 | ), 611 | splitCell, 612 | table( 613 | tr(c(4, 1)), 614 | tr(c11, td(p('foo')), cEmpty, c11), 615 | tr(c11, cEmpty, cEmpty, c11), 616 | ), 617 | )); 618 | 619 | it('distributes column widths', () => 620 | test( 621 | table(tr(td({ colspan: 3, colwidth: [100, 0, 200] }, p('a')))), 622 | splitCell, 623 | table( 624 | tr( 625 | td({ colwidth: [100] }, p('a')), 626 | cEmpty, 627 | td({ colwidth: [200] }, p()), 628 | ), 629 | ), 630 | )); 631 | 632 | describe('with custom cell type', () => { 633 | function createGetCellType(state: EditorState) { 634 | return ({ row }: { row: number }) => { 635 | if (row === 0) { 636 | return state.schema.nodes.table_header; 637 | } 638 | return state.schema.nodes.table_cell; 639 | }; 640 | } 641 | 642 | const splitCellWithOnlyHeaderInColumnZero = ( 643 | state: EditorState, 644 | dispatch?: (tr: Transaction) => void, 645 | ) => splitCellWithType(createGetCellType(state))(state, dispatch); 646 | 647 | it('can split a row-spanning header cell into a header and normal cell ', () => 648 | test( 649 | table(tr(c11, td({ rowspan: 2 }, p('foo')), c11), tr(c11, c11)), 650 | splitCellWithOnlyHeaderInColumnZero, 651 | table(tr(c11, th(p('foo')), c11), tr(c11, cEmpty, c11)), 652 | )); 653 | }); 654 | }); 655 | 656 | describe('setCellAttr', () => { 657 | const cAttr = td({ test: 'value' }, p('x')); 658 | 659 | it('can set an attribute on a parent cell', () => 660 | test( 661 | table(tr(cCursor, c11)), 662 | setCellAttr('test', 'value'), 663 | table(tr(cAttr, c11)), 664 | )); 665 | 666 | it('does nothing when the attribute is already there', () => 667 | test(table(tr(cCursor, c11)), setCellAttr('test', 'default'), null)); 668 | 669 | it('will set attributes on all cells covered by a cell selection', () => 670 | test( 671 | table(tr(c11, cAnchor, c11), tr(c(2, 1), cHead), tr(c11, c11, c11)), 672 | setCellAttr('test', 'value'), 673 | table(tr(c11, cAttr, cAttr), tr(c(2, 1), cAttr), tr(c11, c11, c11)), 674 | )); 675 | }); 676 | 677 | describe('toggleHeaderRow', () => { 678 | it('turns a non-header row into header', () => 679 | test( 680 | doc(table(tr(cCursor, c11), tr(c11, c11))), 681 | toggleHeaderRow, 682 | doc(table(tr(h11, h11), tr(c11, c11))), 683 | )); 684 | 685 | it('turns a header row into regular cells', () => 686 | test( 687 | doc(table(tr(hCursor, h11), tr(c11, c11))), 688 | toggleHeaderRow, 689 | doc(table(tr(c11, c11), tr(c11, c11))), 690 | )); 691 | 692 | it('turns a partial header row into regular cells', () => 693 | test( 694 | doc(table(tr(cCursor, h11), tr(c11, c11))), 695 | toggleHeaderRow, 696 | doc(table(tr(c11, c11), tr(c11, c11))), 697 | )); 698 | 699 | it('leaves cell spans intact', () => 700 | test( 701 | doc(table(tr(cCursor, c(2, 2)), tr(c11), tr(c11, c11, c11))), 702 | toggleHeaderRow, 703 | doc(table(tr(h11, h(2, 2)), tr(c11), tr(c11, c11, c11))), 704 | )); 705 | }); 706 | 707 | describe('toggleHeaderColumn', () => { 708 | it('turns a non-header column into header', () => 709 | test( 710 | doc(table(tr(cCursor, c11), tr(c11, c11))), 711 | toggleHeaderColumn, 712 | doc(table(tr(h11, c11), tr(h11, c11))), 713 | )); 714 | 715 | it('turns a header column into regular cells', () => 716 | test( 717 | doc(table(tr(hCursor, h11), tr(h11, c11))), 718 | toggleHeaderColumn, 719 | doc(table(tr(c11, h11), tr(c11, c11))), 720 | )); 721 | 722 | it('turns a partial header column into regular cells', () => 723 | test( 724 | doc(table(tr(hCursor, c11), tr(c11, c11))), 725 | toggleHeaderColumn, 726 | doc(table(tr(c11, c11), tr(c11, c11))), 727 | )); 728 | }); 729 | 730 | describe('toggleHeader', () => { 731 | it('turns a header row with colspan and rowspan into a regular cell', () => 732 | test( 733 | doc( 734 | p('x'), 735 | table(tr(h(2, 1), h(1, 2)), tr(cCursor, c11), tr(c11, c11, c11)), 736 | ), 737 | toggleHeader('row', { useDeprecatedLogic: false }), 738 | doc( 739 | p('x'), 740 | table(tr(c(2, 1), c(1, 2)), tr(cCursor, c11), tr(c11, c11, c11)), 741 | ), 742 | )); 743 | 744 | it('turns a header column with colspan and rowspan into a regular cell', () => 745 | test( 746 | doc( 747 | p('x'), 748 | table(tr(h(2, 1), h(1, 2)), tr(cCursor, c11), tr(c11, c11, c11)), 749 | ), 750 | toggleHeader('column', { useDeprecatedLogic: false }), 751 | doc(p('x'), table(tr(h(2, 1), h(1, 2)), tr(h11, c11), tr(h11, c11, c11))), 752 | )); 753 | 754 | it('should keep first cell as header when the column header is enabled', () => 755 | test( 756 | doc(p('x'), table(tr(h11, c11), tr(hCursor, c11), tr(h11, c11))), 757 | toggleHeader('row', { useDeprecatedLogic: false }), 758 | doc(p('x'), table(tr(h11, h11), tr(h11, c11), tr(h11, c11))), 759 | )); 760 | 761 | describe('new behavior', () => { 762 | it('turns a header column into regular cells without override header row', () => 763 | test( 764 | doc(table(tr(hCursor, h11), tr(h11, c11))), 765 | toggleHeader('column', { useDeprecatedLogic: false }), 766 | doc(table(tr(hCursor, h11), tr(c11, c11))), 767 | )); 768 | }); 769 | }); 770 | --------------------------------------------------------------------------------