diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js index 1cc60e33e13..deb59fde323 100644 --- a/app/assets/javascripts/content_editor/extensions/sourcemap.js +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -16,6 +16,10 @@ import Paragraph from './paragraph'; import Strike from './strike'; import TaskList from './task_list'; import TaskItem from './task_item'; +import Table from './table'; +import TableCell from './table_cell'; +import TableHeader from './table_header'; +import TableRow from './table_row'; export default Extension.create({ addGlobalAttributes() { @@ -39,6 +43,10 @@ export default Extension.create({ Strike.name, TaskList.name, TaskItem.name, + Table.name, + TableCell.name, + TableHeader.name, + TableRow.name, ], attributes: { sourceMarkdown: { diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js index 92faaabac0d..dce33889c48 100644 --- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js +++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js @@ -22,7 +22,7 @@ import { Mark } from 'prosemirror-model'; import { visitParents } from 'unist-util-visit-parents'; import { toString } from 'hast-util-to-string'; -import { isFunction, noop } from 'lodash'; +import { isFunction, isString, noop } from 'lodash'; /** * Merges two ProseMirror text nodes if both text nodes @@ -290,6 +290,7 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) selector: factorySpec.selector, skipChildren: factorySpec.skipChildren, processText: factorySpec.processText, + parent: factorySpec.parent, }; if (factorySpec.type === 'block') { @@ -361,6 +362,14 @@ const findFactory = (hastNode, ancestors, factories) => : [hastNode.tagName, hastNode.type].includes(selector); })?.[1]; +const findParent = (ancestors, parent) => { + if (isString(parent)) { + return ancestors.reverse().find((ancestor) => ancestor.tagName === parent); + } + + return ancestors[ancestors.length - 1]; +}; + /** * Converts a Hast AST to a ProseMirror document based on a series * of specifications that describe how to map all the nodes of the former @@ -461,6 +470,13 @@ const findFactory = (hastNode, ancestors, factories) => * Use this property along skipChildren to provide custom processing of child nodes * for a block node. * + * **parent** + * + * Specifies what is the node’s parent. This is useful when the node’s parent is not + * its direct ancestor in Abstract Syntax Tree. For example, imagine that you want + * to make elements a direct children of tables and skip `` and `` + * altogether. + * * @param {model.Document_Schema} params.schema A ProseMirror schema that specifies the shape * of the ProseMirror document. * @param {Object} params.factorySpec A factory specification as described above @@ -475,7 +491,6 @@ export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, visitParents(tree, (hastNode, ancestors) => { const factory = findFactory(hastNode, ancestors, proseMirrorNodeFactories); - const parent = ancestors[ancestors.length - 1]; if (!factory) { throw new Error( @@ -485,6 +500,8 @@ export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, ); } + const parent = findParent(ancestors, factory.parent); + factory.handle(state, hastNode, parent); return factory.skipChildren === true ? 'skip' : true; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index dccc545f6cb..d5c4da8c76c 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -193,7 +193,7 @@ const defaultSerializerConfig = { state.write('[[_TOC_]]'); state.closeBlock(node); }, - [Table.name]: renderTable, + [Table.name]: preserveUnchanged(renderTable), [TableCell.name]: renderTableCell, [TableHeader.name]: renderTableCell, [TableRow.name]: renderTableRow, diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index bec18bcdd81..9fb0d520848 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -10,6 +10,11 @@ const isTaskItem = (hastNode) => { ); }; +const getTableCellAttrs = (hastNode) => ({ + colspan: parseInt(hastNode.properties.colSpan, 10) || 1, + rowspan: parseInt(hastNode.properties.rowSpan, 10) || 1, +}); + const factorySpecs = { blockquote: { type: 'block', selector: 'blockquote' }, paragraph: { type: 'block', selector: 'p' }, @@ -81,6 +86,31 @@ const factorySpecs = { selector: (hastNode, ancestors) => hastNode.tagName === 'input' && isTaskItem(ancestors[ancestors.length - 1]), }, + table: { + type: 'block', + selector: 'table', + }, + tableRow: { + type: 'block', + selector: 'tr', + parent: 'table', + }, + tableHeader: { + type: 'block', + selector: 'th', + getAttrs: getTableCellAttrs, + wrapTextInParagraph: true, + }, + tableCell: { + type: 'block', + selector: 'td', + getAttrs: getTableCellAttrs, + wrapTextInParagraph: true, + }, + ignoredTableNodes: { + type: 'ignore', + selector: (hastNode) => ['thead', 'tbody', 'tfoot'].includes(hastNode.tagName), + }, image: { type: 'inline', selector: 'img', diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 4019b772269..055a32420b2 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -1,4 +1,4 @@ -import { uniq, isString } from 'lodash'; +import { uniq, isString, omit } from 'lodash'; const defaultAttrs = { td: { colspan: 1, rowspan: 1, colwidth: null }, @@ -219,7 +219,7 @@ function renderTableRowAsHTML(state, node) { node.forEach((cell, _, i) => { const tag = cell.type.name === 'tableHeader' ? 'th' : 'td'; - renderTagOpen(state, tag, cell.attrs); + renderTagOpen(state, tag, omit(cell.attrs, 'sourceMapKey', 'sourceMarkdown')); if (!containsParagraphWithOnlyText(cell)) { state.closeBlock(node); diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index c2323d6b286..41f7a4b147f 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,5 +1,5 @@