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 75454f1748f..46702e75e9b 100644 --- a/src/nodes/Table/Table.js +++ b/src/nodes/Table/Table.js @@ -181,6 +181,118 @@ export default Table.extend({ ) dispatch(tr.setSelection(selection).scrollIntoView()) } + return true + }, + sortColumn: + (direction = 'asc', cell = null) => + ({ state, tr, dispatch }) => { + if ( + cell?.type?.name !== 'tableCell' + && cell?.type?.name !== 'tableHeader' + ) { + return false + } + + // find the table, its position and the column index of the cell + let table = null + let tablePos = null + let columnIndex = -1 + + state.doc.descendants((node, pos) => { + if (node.type.name !== 'table') { + return true + } + + for ( + let rowIndex = 0; + rowIndex < node.childCount; + rowIndex += 1 + ) { + const row = node.child(rowIndex) + for ( + let currentColumnIndex = 0; + currentColumnIndex < row.childCount; + currentColumnIndex += 1 + ) { + if (row.child(currentColumnIndex) === cell) { + table = node + tablePos = pos + columnIndex = currentColumnIndex + return false + } + } + } + + return true + }) + + if (!table || tablePos === null || columnIndex < 0) return false + + 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: row.child(columnIndex).textContent.trim(), + })) + .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 73ff713c245..75da6d14158 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 cell + cell = getHeaderCell(editor, 0) + expect(editor.commands.sortColumn('asc', cell)).toBe(true) + + expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10']) + expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a']) + + cell = getHeaderCell(editor, 1) + expect(editor.commands.sortColumn('asc', cell)).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 cell + cell = getHeaderCell(editor, 0) + expect(editor.commands.sortColumn('desc', cell)).toBe(true) + + expect(getBodyColumnValues(editor, 0)).toEqual(['10', '2', '1']) + expect(getBodyColumnValues(editor, 1)).toEqual(['a', 'b', 'c']) + + cell = getHeaderCell(editor, 1) + expect(editor.commands.sortColumn('desc', cell)).toBe(true) + + expect(getBodyColumnValues(editor, 0)).toEqual(['1', '2', '10']) + expect(getBodyColumnValues(editor, 1)).toEqual(['c', 'b', 'a']) + }) }) +const getHeaderCell = (editor, targetIndex = 0) => { + let cell + editor.state.doc.descendants((node, pos) => { + if (!['tableHeadRow', 'tableRow'].includes(node.type.name)) { + return true + } + if (targetIndex >= node.childCount) { + return false + } + + cell = node.child(targetIndex) + return false + }) + return cell +} + +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$/, '') }