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 @@
@@ -48,6 +48,24 @@
+
+
+
+
+ {{ 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$/, '')
}