diff options
author | Max <max@nextcloud.com> | 2022-03-09 22:07:17 +0300 |
---|---|---|
committer | Max <max@nextcloud.com> | 2022-03-31 15:29:14 +0300 |
commit | 4629457932accaa24abefc9a8f444dc9398a94d5 (patch) | |
tree | 9d0a5d312e588f627ed905c25c8b13d34060610a | |
parent | 0db660bbe12c948e1964915f483ad8e406f72d4d (diff) |
feature: initial table support
First take at parsing and serializing markdown tables.
Use TableHead and TableBody nodes to mimic the markdown table structure
with a single heading line on top and multiple td lines following.
Signed-off-by: Max <max@nextcloud.com>
-rw-r--r-- | package-lock.json | 120 | ||||
-rw-r--r-- | package.json | 13 | ||||
-rw-r--r-- | src/EditorFactory.js | 12 | ||||
-rw-r--r-- | src/markdownit/index.js | 1 | ||||
-rw-r--r-- | src/nodes/Table.js | 41 | ||||
-rw-r--r-- | src/nodes/TableBody.js | 23 | ||||
-rw-r--r-- | src/nodes/TableCell.js | 8 | ||||
-rw-r--r-- | src/nodes/TableHead.js | 49 | ||||
-rw-r--r-- | src/nodes/TableHeader.js | 8 | ||||
-rw-r--r-- | src/nodes/TableRow.js | 8 | ||||
-rw-r--r-- | src/tests/fixtures/table.html | 14 | ||||
-rw-r--r-- | src/tests/fixtures/table.md | 3 | ||||
-rw-r--r-- | src/tests/fixtures/tableWithOtherStructure.html | 10 | ||||
-rw-r--r-- | src/tests/markdownit.spec.js | 7 | ||||
-rw-r--r-- | src/tests/tables.spec.js | 36 |
15 files changed, 351 insertions, 2 deletions
diff --git a/package-lock.json b/package-lock.json index 495bf7aa7..19413d4dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,10 @@ "@tiptap/extension-paragraph": "^2.0.0-beta.22", "@tiptap/extension-placeholder": "^2.0.0-beta.44", "@tiptap/extension-strike": "^2.0.0-beta.26", + "@tiptap/extension-table": "^2.0.0-beta.48", + "@tiptap/extension-table-cell": "^2.0.0-beta.20", + "@tiptap/extension-table-header": "^2.0.0-beta.22", + "@tiptap/extension-table-row": "^2.0.0-beta.19", "@tiptap/extension-task-item": "^2.0.0-beta.31", "@tiptap/extension-task-list": "^2.0.0-beta.26", "@tiptap/extension-text": "^2.0.0-beta.15", @@ -83,6 +87,7 @@ "eslint-plugin-cypress": "^2.12.1", "jest": "^27.5.1", "jest-environment-jsdom": "^27.5.1", + "jest-raw-loader": "^1.0.1", "jest-serializer-vue": "^2.0.2", "regenerator-runtime": "^0.13.9" }, @@ -3758,6 +3763,58 @@ "@tiptap/core": "^2.0.0-beta.1" } }, + "node_modules/@tiptap/extension-table": { + "version": "2.0.0-beta.48", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.0.0-beta.48.tgz", + "integrity": "sha512-Hcx3kOBQyazQ3dV0Oq4zKIl1og4EqUuZ5nEWxwcb8HgxSUYIhAJQ7pujPZiRLfkoFy92oVwmh9KhBRfQqRkUpQ==", + "dependencies": { + "prosemirror-tables": "^1.1.1", + "prosemirror-view": "^1.23.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0-beta.1" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "2.0.0-beta.20", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.0.0-beta.20.tgz", + "integrity": "sha512-IllQyxLQvgm1FAewz3U+DkgNHRthmuVrtUQnG6la45qdUOLCOrpFbRRaQ1LJ/BpbvZ2Xs1o2yAa97BqZOPwovQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0-beta.1" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "2.0.0-beta.22", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.0.0-beta.22.tgz", + "integrity": "sha512-nMrghrfl+ZS4EDixs3lgXnHw1Q+ECyTugpRvS36rP7b8GFp3GXm9DfbIAUzwGGfcq1D7DwRnJUDM6ARdWXyw0w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0-beta.1" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "2.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.0.0-beta.19.tgz", + "integrity": "sha512-ldEVDpIUX7ZqbViTy4c/RfyNGRv++O/r3A/Ivuon1PykaDDTbPlp5JM89FunAD39cLAbo2HKtweqdmzCMlZsqA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0-beta.1" + } + }, "node_modules/@tiptap/extension-task-item": { "version": "2.0.0-beta.31", "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.0.0-beta.31.tgz", @@ -13256,6 +13313,12 @@ } } }, + "node_modules/jest-raw-loader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz", + "integrity": "sha1-zp9W1UZQ8VfEp9FtIkul1hO81iY=", + "dev": true + }, "node_modules/jest-regex-util": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", @@ -16881,6 +16944,18 @@ "prosemirror-transform": "^1.0.0" } }, + "node_modules/prosemirror-tables": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.1.1.tgz", + "integrity": "sha512-LmCz4jrlqQZRsYRDzCRYf/pQ5CUcSOyqZlAj5kv67ZWBH1SVLP2U9WJEvQfimWgeRlIz0y0PQVqO1arRm1+woA==", + "dependencies": { + "prosemirror-keymap": "^1.1.2", + "prosemirror-model": "^1.8.1", + "prosemirror-state": "^1.3.1", + "prosemirror-transform": "^1.2.1", + "prosemirror-view": "^1.13.3" + } + }, "node_modules/prosemirror-transform": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.4.0.tgz", @@ -24058,6 +24133,33 @@ "integrity": "sha512-2dmCgtesuDdivM/54Q+Y6Tc3JbGz1SkHP6c62piuqBiYLWg3xa16zChZOhfN8szbbQlBgLT6XRTDt3c2Ux+Dug==", "requires": {} }, + "@tiptap/extension-table": { + "version": "2.0.0-beta.48", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.0.0-beta.48.tgz", + "integrity": "sha512-Hcx3kOBQyazQ3dV0Oq4zKIl1og4EqUuZ5nEWxwcb8HgxSUYIhAJQ7pujPZiRLfkoFy92oVwmh9KhBRfQqRkUpQ==", + "requires": { + "prosemirror-tables": "^1.1.1", + "prosemirror-view": "^1.23.6" + } + }, + "@tiptap/extension-table-cell": { + "version": "2.0.0-beta.20", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.0.0-beta.20.tgz", + "integrity": "sha512-IllQyxLQvgm1FAewz3U+DkgNHRthmuVrtUQnG6la45qdUOLCOrpFbRRaQ1LJ/BpbvZ2Xs1o2yAa97BqZOPwovQ==", + "requires": {} + }, + "@tiptap/extension-table-header": { + "version": "2.0.0-beta.22", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.0.0-beta.22.tgz", + "integrity": "sha512-nMrghrfl+ZS4EDixs3lgXnHw1Q+ECyTugpRvS36rP7b8GFp3GXm9DfbIAUzwGGfcq1D7DwRnJUDM6ARdWXyw0w==", + "requires": {} + }, + "@tiptap/extension-table-row": { + "version": "2.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.0.0-beta.19.tgz", + "integrity": "sha512-ldEVDpIUX7ZqbViTy4c/RfyNGRv++O/r3A/Ivuon1PykaDDTbPlp5JM89FunAD39cLAbo2HKtweqdmzCMlZsqA==", + "requires": {} + }, "@tiptap/extension-task-item": { "version": "2.0.0-beta.31", "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.0.0-beta.31.tgz", @@ -31486,6 +31588,12 @@ "dev": true, "requires": {} }, + "jest-raw-loader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz", + "integrity": "sha1-zp9W1UZQ8VfEp9FtIkul1hO81iY=", + "dev": true + }, "jest-regex-util": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", @@ -34314,6 +34422,18 @@ "prosemirror-transform": "^1.0.0" } }, + "prosemirror-tables": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.1.1.tgz", + "integrity": "sha512-LmCz4jrlqQZRsYRDzCRYf/pQ5CUcSOyqZlAj5kv67ZWBH1SVLP2U9WJEvQfimWgeRlIz0y0PQVqO1arRm1+woA==", + "requires": { + "prosemirror-keymap": "^1.1.2", + "prosemirror-model": "^1.8.1", + "prosemirror-state": "^1.3.1", + "prosemirror-transform": "^1.2.1", + "prosemirror-view": "^1.13.3" + } + }, "prosemirror-transform": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.4.0.tgz", diff --git a/package.json b/package.json index 872c15a2c..d77292092 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,10 @@ "@tiptap/extension-paragraph": "^2.0.0-beta.22", "@tiptap/extension-placeholder": "^2.0.0-beta.44", "@tiptap/extension-strike": "^2.0.0-beta.26", + "@tiptap/extension-table": "^2.0.0-beta.48", + "@tiptap/extension-table-cell": "^2.0.0-beta.20", + "@tiptap/extension-table-header": "^2.0.0-beta.22", + "@tiptap/extension-table-row": "^2.0.0-beta.19", "@tiptap/extension-task-item": "^2.0.0-beta.31", "@tiptap/extension-task-list": "^2.0.0-beta.26", "@tiptap/extension-text": "^2.0.0-beta.15", @@ -104,6 +108,7 @@ "eslint-plugin-cypress": "^2.12.1", "jest": "^27.5.1", "jest-environment-jsdom": "^27.5.1", + "jest-raw-loader": "^1.0.1", "jest-serializer-vue": "^2.0.2", "regenerator-runtime": "^0.13.9" }, @@ -112,7 +117,9 @@ "testEnvironment": "jest-environment-jsdom", "moduleFileExtensions": [ "js", - "vue" + "vue", + "md", + "html" ], "moduleNameMapper": { "^@/(.*)$": "<rootDir>/src/$1" @@ -124,7 +131,9 @@ ], "transform": { "^.+\\.js$": "<rootDir>/node_modules/babel-jest", - ".*\\.(vue)$": "<rootDir>/node_modules/@vue/vue2-jest" + ".*\\.(vue)$": "<rootDir>/node_modules/@vue/vue2-jest", + ".*\\.md$": "jest-raw-loader", + ".*\\.html$": "jest-raw-loader" }, "snapshotSerializers": [ "<rootDir>/node_modules/jest-serializer-vue" diff --git a/src/EditorFactory.js b/src/EditorFactory.js index ee31c96bf..ec681c489 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -32,6 +32,12 @@ import ListItem from '@tiptap/extension-list-item' import CodeBlock from '@tiptap/extension-code-block' import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' import HorizontalRule from '@tiptap/extension-horizontal-rule' +import Table from './nodes/Table' +import TableBody from './nodes/TableBody' +import TableCell from './nodes/TableCell' +import TableHead from './nodes/TableHead' +import TableHeader from './nodes/TableHeader' +import TableRow from './nodes/TableRow' /* eslint-enable import/no-named-as-default */ import { Editor } from '@tiptap/core' @@ -88,6 +94,12 @@ const createEditor = ({ content, onCreate, onUpdate, extensions, enableRichEditi HorizontalRule, OrderedList, ListItem, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, TaskList, TaskItem, Callout, diff --git a/src/markdownit/index.js b/src/markdownit/index.js index 5c8d35b14..d4c559533 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -6,6 +6,7 @@ import callouts from './callouts' const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .enable('strikethrough') + .enable('table') .use(taskLists, { enable: true, labelAfter: true }) .use(splitMixedLists) .use(underline) diff --git a/src/nodes/Table.js b/src/nodes/Table.js new file mode 100644 index 000000000..12bdf6486 --- /dev/null +++ b/src/nodes/Table.js @@ -0,0 +1,41 @@ +import { Table } from '@tiptap/extension-table' +import { mergeAttributes } from '@tiptap/core' + +export default Table.extend({ + content: 'tableHead tableBody', + + renderHTML({ HTMLAttributes }) { + return ['table', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + toMarkdown(state, node) { + const widths = [] + const head = node.child(0) + const body = node.child(1) + head.forEach(row => { + state.write('|') + row.forEach(cell => { + widths.push(cell.textContent.length) + state.renderInline(cell) + state.write('|') + }) + }) + state.ensureNewLine() + state.write('|') + widths.forEach(width => { + state.write(state.repeat('-', width)) + state.write('|') + }) + body.forEach(row => { + state.ensureNewLine() + state.write('|') + row.forEach((cell, _, i) => { + state.renderInline(cell) + state.write(state.repeat(' ', widths[i] - cell.textContent.length)) + state.write('|') + }) + }) + state.closeBlock(node) + }, + +}) diff --git a/src/nodes/TableBody.js b/src/nodes/TableBody.js new file mode 100644 index 000000000..fa3cc83ba --- /dev/null +++ b/src/nodes/TableBody.js @@ -0,0 +1,23 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export default Node.create({ + name: 'tableBody', + content: 'tableRow', + + addOptions() { + return { + HTMLAttributes: {}, + } + }, + + parseHTML() { + return [ + { tag: 'tbody' }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['tbody', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + +}) diff --git a/src/nodes/TableCell.js b/src/nodes/TableCell.js new file mode 100644 index 000000000..606f2b428 --- /dev/null +++ b/src/nodes/TableCell.js @@ -0,0 +1,8 @@ +import { TableCell } from '@tiptap/extension-table-cell' + +export default TableCell.extend({ + content: 'inline*', + addAttributes() { + return {} + }, +}) diff --git a/src/nodes/TableHead.js b/src/nodes/TableHead.js new file mode 100644 index 000000000..6e45a3095 --- /dev/null +++ b/src/nodes/TableHead.js @@ -0,0 +1,49 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +const tableHeadRow = Node.create({ + name: 'tableHeadRow', + content: 'tableHeader*', + + addOptions() { + return { + HTMLAttributes: {}, + } + }, + + parseHTML() { + return [ + { tag: 'tr' }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + +}) + +export default Node.create({ + name: 'tableHead', + content: 'tableHeadRow', + + addOptions() { + return { + HTMLAttributes: {}, + } + }, + + addExtensions() { + return [tableHeadRow] + }, + + parseHTML() { + return [ + { tag: 'thead' }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['thead', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + +}) diff --git a/src/nodes/TableHeader.js b/src/nodes/TableHeader.js new file mode 100644 index 000000000..b50603bca --- /dev/null +++ b/src/nodes/TableHeader.js @@ -0,0 +1,8 @@ +import { TableHeader } from '@tiptap/extension-table-header' + +export default TableHeader.extend({ + content: 'inline*', + addAttributes() { + return {} + }, +}) diff --git a/src/nodes/TableRow.js b/src/nodes/TableRow.js new file mode 100644 index 000000000..c42aeee34 --- /dev/null +++ b/src/nodes/TableRow.js @@ -0,0 +1,8 @@ +import { TableRow } from '@tiptap/extension-table-row' + +export default TableRow.extend({ + content: 'tableCell*', + addAttributes() { + return {} + }, +}) diff --git a/src/tests/fixtures/table.html b/src/tests/fixtures/table.html new file mode 100644 index 000000000..f260c28a0 --- /dev/null +++ b/src/tests/fixtures/table.html @@ -0,0 +1,14 @@ +<table> +<thead> +<tr> +<th>heading</th> +<th>other heading</th> +</tr> +</thead> +<tbody> +<tr> +<td>cell</td> +<td>other cell</td> +</tr> +</tbody> +</table> diff --git a/src/tests/fixtures/table.md b/src/tests/fixtures/table.md new file mode 100644 index 000000000..1e7479f58 --- /dev/null +++ b/src/tests/fixtures/table.md @@ -0,0 +1,3 @@ +|heading|other heading| +|-------|-------------| +|cell |other cell | diff --git a/src/tests/fixtures/tableWithOtherStructure.html b/src/tests/fixtures/tableWithOtherStructure.html new file mode 100644 index 000000000..cc56a1799 --- /dev/null +++ b/src/tests/fixtures/tableWithOtherStructure.html @@ -0,0 +1,10 @@ +<table> + <tr> + <th>heading</th> + <td>other heading</td> + </tr> + <tr> + <th>cell</th> + <td>other cell</td> + </tr> +</table> diff --git a/src/tests/markdownit.spec.js b/src/tests/markdownit.spec.js index 69ce3673d..30748860a 100644 --- a/src/tests/markdownit.spec.js +++ b/src/tests/markdownit.spec.js @@ -1,5 +1,7 @@ import markdownit from './../markdownit' import { typesAvailable } from '../markdownit/callouts' +import tableMarkdown from './fixtures/table.md' +import tableHtml from './fixtures/table.html' describe('markdownit', () => { @@ -27,6 +29,11 @@ describe('markdownit', () => { `)) }) + it('renders tables', () => { + const rendered = markdownit.render(tableMarkdown) + expect(rendered).toBe(tableHtml) + }) + describe('callouts', () => { typesAvailable.forEach((type) => { it(`render ${type}`, () => { diff --git a/src/tests/tables.spec.js b/src/tests/tables.spec.js new file mode 100644 index 000000000..7a347eb9e --- /dev/null +++ b/src/tests/tables.spec.js @@ -0,0 +1,36 @@ +import { createEditor } from './../EditorFactory' +import { createMarkdownSerializer } from './../extensions/Markdown' +import markdownit from './../markdownit' +import input from './fixtures/table.md' +import output from './fixtures/table.html' +import otherStructure from './fixtures/tableWithOtherStructure.html' + +describe('Table', () => { + + test('load into editor', () => { + const tiptap = editorWithContent(markdownit.render(input)) + expect(tiptap.getHTML().replaceAll('><', ">\n<")).toBe(output.replace(/\n$/, '')) + }) + + test('serialize from editor', () => { + const tiptap = editorWithContent(markdownit.render(input)) + const serializer = createMarkdownSerializer(tiptap.schema) + expect(serializer.serialize(tiptap.state.doc)).toBe(input.replace(/\n$/, '')) + }) + + test('handle html table with other structure', () => { + const tiptap = editorWithContent( + otherStructure.replace(/\n\s*/g,'') + ) + expect(tiptap.getHTML().replaceAll('><', ">\n<")).toBe(output.replace(/\n$/, '')) + }) + +}) + +function editorWithContent(content) { + return createEditor({ + content, + enableRichEditing: true + }) +} + |