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