diff --git a/src/components/icons.js b/src/components/icons.js index b2ec9357ef7..de671a33c70 100644 --- a/src/components/icons.js +++ b/src/components/icons.js @@ -55,6 +55,8 @@ import MDI_Pencil from 'vue-material-design-icons/PencilOutline.vue' import MDI_Plus from 'vue-material-design-icons/Plus.vue' import MDI_Shape from 'vue-material-design-icons/ShapeOutline.vue' import MDI_Sigma from 'vue-material-design-icons/Sigma.vue' +import MDI_SortAscending from 'vue-material-design-icons/SortAscending.vue' +import MDI_SortDescending from 'vue-material-design-icons/SortDescending.vue' import MDI_Table from 'vue-material-design-icons/Table.vue' import MDI_TableSettings from 'vue-material-design-icons/TableCog.vue' import MDI_TableAddColumnAfter from 'vue-material-design-icons/TableColumnPlusAfter.vue' @@ -152,3 +154,5 @@ export const Warn = makeIcon(MDI_Warn) export const Web = makeIcon(MDI_Web) export const Plus = makeIcon(MDI_Plus) export const Sigma = makeIcon(MDI_Sigma) +export const SortAscending = makeIcon(MDI_SortAscending) +export const SortDescending = makeIcon(MDI_SortDescending) diff --git a/src/nodes/Table/Table.js b/src/nodes/Table/Table.js index 6e08b327bf8..b7f92437206 100644 --- a/src/nodes/Table/Table.js +++ b/src/nodes/Table/Table.js @@ -72,6 +72,31 @@ function findSameCellInNextRow($cell) { } } +const getSortableCellText = (cell) => cell.textContent.trim() + +const isTableCellType = (node) => + node?.type?.name === 'tableCell' || node?.type?.name === 'tableHeader' + +const resolveTableCellFromPosition = (doc, position) => { + const tryResolve = (pos) => { + if (typeof pos !== 'number' || pos < 0 || pos > doc.content.size) { + return null + } + const $pos = doc.resolve(pos) + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (isTableCellType($pos.node(depth))) { + return { $cell: $pos, cellDepth: depth } + } + } + return null + } + + return ( + tryResolve(position) + || tryResolve(typeof position === 'number' ? position + 1 : position) + ) +} + export default Table.extend({ content: 'tableCaption? tableHeadRow tableRow*', @@ -181,6 +206,97 @@ export default Table.extend({ ) dispatch(tr.setSelection(selection).scrollIntoView()) } + return true + }, + sortColumn: + (direction = 'asc', cell = null) => + ({ state, tr, dispatch }) => { + const resolvedCell = resolveTableCellFromPosition( + state.doc, + cell, + ) + if (!resolvedCell) return false + + const { $cell, cellDepth } = resolvedCell + const columnIndex = $cell.index(cellDepth - 1) + + // find the table node + let tableDepth = $cell.depth + while ( + tableDepth > 0 + && $cell.node(tableDepth).type.name !== 'table' + ) { + tableDepth -= 1 + } + if (tableDepth === 0) return false + + const table = $cell.node(tableDepth) + const tablePos = $cell.before(tableDepth) + const bodyRows = [] + const nonBodyChildren = [] + table.forEach((child) => { + if (child.type.name === 'tableRow') { + bodyRows.push(child) + return + } + nonBodyChildren.push(child) + }) + if (bodyRows.length < 2) return true + + // check if all rows have a cell at the column index and that the cell doesn't have colspan or rowspan + const canSortRows = bodyRows.every((row) => { + if (columnIndex >= row.childCount) { + return false + } + const targetCell = row.child(columnIndex) + return ( + (targetCell.attrs.colspan ?? 1) === 1 + && (targetCell.attrs.rowspan ?? 1) === 1 + ) + }) + if (!canSortRows) return false + + // sort the rows based on the content of the cell at the column index + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', + }) + const sortDirection = direction === 'desc' ? -1 : 1 + const sortedRows = bodyRows + .map((row, index) => ({ + index, + row, + key: getSortableCellText(row.child(columnIndex)), + })) + .sort((a, b) => { + const keyCompare = + collator.compare(a.key, b.key) * sortDirection + if (keyCompare !== 0) { + return keyCompare + } + return a.index - b.index + }) + + const hasChangedOrder = sortedRows.some( + ({ index }, sortedIndex) => index !== sortedIndex, + ) + if (!hasChangedOrder) return true + + const sortedTable = table.type.createChecked( + table.attrs, + [...nonBodyChildren, ...sortedRows.map(({ row }) => row)], + table.marks, + ) + + if (dispatch) { + tr.replaceWith( + tablePos, + tablePos + table.nodeSize, + sortedTable, + ) + dispatch(tr.scrollIntoView()) + } + return true }, } diff --git a/src/nodes/Table/TableHeaderView.vue b/src/nodes/Table/TableHeaderView.vue index 9633db8b989..9407d2eba84 100644 --- a/src/nodes/Table/TableHeaderView.vue +++ b/src/nodes/Table/TableHeaderView.vue @@ -1,6 +1,6 @@ + + + {{ t('text', 'Sort ascending') }} + + + + {{ t('text', 'Sort descending') }} + { expect(editor.getHTML()).toBe(editorHtml) } }) + + test('sorts table body rows in ascending order by selected column', ({ + editor, + }) => { + editor.commands.setContent( + markdownit.render( + '| col0 | col1 |\n|---|---|\n| 2 | b |\n| 10 | a |\n| 1 | c |\n', + ), + ) + + let cellPos + cellPos = getHeaderCellPos(editor, 0) + expect(editor.commands.sortColumn('asc', cellPos)).toBe(true) + + expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10']) + expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a']) + + cellPos = getHeaderCellPos(editor, 1) + expect(editor.commands.sortColumn('asc', cellPos)).toBe(true) + + expect(getBodyColumnValues(editor, 0)).toEqual(['10', '2', '1']) + expect(getBodyColumnValues(editor, 1)).toEqual(['a', 'b', 'c']) + }) + + test('sorts table body rows in descending order by selected column', ({ + editor, + }) => { + editor.commands.setContent( + markdownit.render( + '| col0 | col1 |\n|---|---|\n| 2 | b |\n| 10 | a |\n| 1 | c |\n', + ), + ) + + let cellPos + cellPos = getHeaderCellPos(editor, 0) + expect(editor.commands.sortColumn('desc', cellPos)).toBe(true) + + expect(getBodyColumnValues(editor, 0)).toEqual(['10', '2', '1']) + expect(getBodyColumnValues(editor, 1)).toEqual(['a', 'b', 'c']) + + cellPos = getHeaderCellPos(editor, 1) + expect(editor.commands.sortColumn('desc', cellPos)).toBe(true) + + expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10']) + expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a']) + }) }) +const getHeaderCellPos = (editor, targetIndex = 0) => { + let cellPos + editor.state.doc.descendants((node, pos) => { + if (!['tableHeadRow', 'tableRow'].includes(node.type.name)) { + return true + } + if (targetIndex >= node.childCount) { + return false + } + + cellPos = pos + 1 + for (let index = 0; index < targetIndex; index += 1) { + cellPos += node.child(index).nodeSize + } + return false + }) + return cellPos +} + +const getBodyColumnValues = (editor, columnIndex) => { + const values = [] + editor.state.doc.descendants((node) => { + if (node.type.name !== 'tableRow') { + return true + } + if (columnIndex < node.childCount) { + values.push(node.child(columnIndex).textContent.trim()) + } + return true + }) + return values +} + const formatHTML = (html) => { return html.replaceAll('><', '>\n<').replace(/\n$/, '') }