Table auto-sort #1244

Open
opened 2026-02-05 00:22:11 +03:00 by OVERLORD · 31 comments
Owner

Originally created by @FMCUSystemAdmins on GitHub (Jul 2, 2019).

Apologies if this has been submitted already, but I didn't see anything out there. We use quite a bit of tables in our documentation and have a need for them to be alphabetically sorted based on a particular column (in our case, the first column). We would like to request an auto-sort feature for tables that would sort them alphabetically based on a chosen column.

This would ease the management of tables that have quite a bit of rows as well as any future additions to the table.

Originally created by @FMCUSystemAdmins on GitHub (Jul 2, 2019). Apologies if this has been submitted already, but I didn't see anything out there. We use quite a bit of tables in our documentation and have a need for them to be alphabetically sorted based on a particular column (in our case, the first column). We would like to request an auto-sort feature for tables that would sort them alphabetically based on a chosen column. This would ease the management of tables that have quite a bit of rows as well as any future additions to the table.
OVERLORD added the 🛠️ Enhancement💆 UX💻 Front-End labels 2026-02-05 00:22:11 +03:00
Author
Owner

@ssddanbrown commented on GitHub (Jul 3, 2019):

Thanks for the suggestion @FMCUSystemAdmins, I can see how this would be useful. Just to confirm, Do you desire this feature primarily as an editor of a table or as a viewer of a table?

@ssddanbrown commented on GitHub (Jul 3, 2019): Thanks for the suggestion @FMCUSystemAdmins, I can see how this would be useful. Just to confirm, Do you desire this feature primarily as an editor of a table or as a viewer of a table?
Author
Owner

@FMCUSystemAdmins commented on GitHub (Jul 4, 2019):

We’re finding a need for it from the editor standpoint.

@FMCUSystemAdmins commented on GitHub (Jul 4, 2019): We’re finding a need for it from the editor standpoint.
Author
Owner

@essdeeay commented on GitHub (Feb 6, 2020):

I've added +1 for this as a feature.

@essdeeay commented on GitHub (Feb 6, 2020): I've added +1 for this as a feature.
Author
Owner

@Mr-Sloth commented on GitHub (Feb 12, 2021):

Eventually a different table plugin would make sense. Something like a filter, sort, searchable table would be overpowered. But sorting in edit mode is also a great addition.

@Mr-Sloth commented on GitHub (Feb 12, 2021): Eventually a different table plugin would make sense. Something like a filter, sort, searchable table would be overpowered. But sorting in edit mode is also a great addition.
Author
Owner

@C493 commented on GitHub (Aug 23, 2021):

I'd like to +1 this request.
I would also like viewers of the page to be able to sort tables by any column by clicking on the column heading.

@C493 commented on GitHub (Aug 23, 2021): I'd like to +1 this request. I would also like viewers of the page to be able to sort tables by any column by clicking on the column heading.
Author
Owner

@gschirinzi commented on GitHub (Sep 21, 2021):

+1 for this request
Mainly for viewers to sort tables on any desired column

@gschirinzi commented on GitHub (Sep 21, 2021): +1 for this request Mainly for viewers to sort tables on any desired column
Author
Owner

@Montg0mery commented on GitHub (Mar 25, 2022):

Even just having client-side table sorting when viewing a page would be very useful, as a starting point.

@Montg0mery commented on GitHub (Mar 25, 2022): Even just having client-side table sorting when viewing a page would be very useful, as a starting point.
Author
Owner

@crpb commented on GitHub (Mar 25, 2022):

For those who want sorting at least in the view.

As you can just copy the sorted Table and overwrite the current Content you can at least do some basic Formating with it without relying on something like 'LibreOffice-Calc'.

Custom HTML Head Entry

<!-- https://datatables.net -->
<script src="/localjs/jquery-3.6.0.min.js"></script>
<link rel="stylesheet" type="text/css" href="/localjs/datatables.min.css"/>
<script type="text/javascript" src="/localjs/datatables.min.js"></script>
<script>
$(document).ready(function() {
  $("[id^=bkmrk-sorted-table], table.sorted").DataTable({
    "language": {
       "url": "/localjs/de_de.json"
    },
    "order": []			
  })
})
</script>

I use this nginx-snippet, so i don't have to rely on external Sources.

location /localjs {
  autoindex on;
  alias /var/www/html/localjs/;
}

The autoindex can be left out naturally.
image

@crpb commented on GitHub (Mar 25, 2022): For those who want sorting at least in the view. As you can just copy the sorted Table and overwrite the current Content you can at least do some _basic Formating_ with it without relying on something like 'LibreOffice-Calc'. [Custom HTML Head Entry](https://www.bookstackapp.com/docs/admin/hacking-bookstack/) ``` <!-- https://datatables.net --> <script src="/localjs/jquery-3.6.0.min.js"></script> <link rel="stylesheet" type="text/css" href="/localjs/datatables.min.css"/> <script type="text/javascript" src="/localjs/datatables.min.js"></script> <script> $(document).ready(function() { $("[id^=bkmrk-sorted-table], table.sorted").DataTable({ "language": { "url": "/localjs/de_de.json" }, "order": [] }) }) </script> ``` I use this nginx-snippet, so i don't have to rely on external Sources. ``` location /localjs { autoindex on; alias /var/www/html/localjs/; } ``` The `autoindex` can be left out naturally. ![image](https://user-images.githubusercontent.com/5575140/160182718-8d37f32e-29e8-4f03-983a-ea9af60b1e36.png)
Author
Owner

@robert-andreas commented on GitHub (Apr 11, 2022):

<script type="text/javascript" src="/localjs/datatables.min.js"></script>

Could you share the content of that file :-)?

@robert-andreas commented on GitHub (Apr 11, 2022): > <script type="text/javascript" src="/localjs/datatables.min.js"></script> Could you share the content of that file :-)?
Author
Owner

@crpb commented on GitHub (Apr 11, 2022):

Could be that someone did a few modifications in the office. I'm not all versed in Javascript :-).
https://gist.github.com/crpb/705dbebe326573e6a36c12c415207d59

@crpb commented on GitHub (Apr 11, 2022): Could be that someone did a few modifications in the office. I'm not all versed in Javascript :-). https://gist.github.com/crpb/705dbebe326573e6a36c12c415207d59
Author
Owner

@ssddanbrown commented on GitHub (Dec 13, 2022):

Here's my no-library take for in-WYSIWYG-editor table sorting. Allows sorting via double clicking column headers.
Supports inverse sort on re-sort of same column. Lightly tested on v22.11, Might have side-affects.
Just needs to be added to the "Custom HTML Head Content" customization setting:

<script>

    // Hook into the WYSIWYG editor setup event and add our logic once loaded
    window.addEventListener('editor-tinymce::setup', event => {
        const editor = event.detail.editor;
        setupTableSort(editor);
    });

    // Setup the required event handler, listening for double-click on table cells.
    function setupTableSort(editor) {
        editor.on('dblclick', event => {
             const target = event.target;
             const parentHeader = target.closest('table tr:first-child td, table tr:first-child th');
             if (parentHeader) {
                 // Sort out table within a transaction so this can be undone in the editor if required.
                 editor.undoManager.transact(() => {
                     sortTable(parentHeader, editor);
                 });
             }
        });
    }

    // Sort the parent table of the given header cell that was clicked.
    function sortTable(headerCell) {
        const table = headerCell.closest('table');
        // Exit if the table has a header row but the clicked cell was not part of that header
        if (table.querySelector('thead') && headerCell.closest('thead') === null) {
            return;
        }

        const headerRow = headerCell.parentNode;
        const headerIndex = [...headerRow.children].indexOf(headerCell);
        const tbody = table.querySelector('tbody');
        const rowsToSort = [...table.querySelectorAll('tbody tr')].filter(tr => tr !== headerRow);
        const invert = headerCell.dataset.sorted === 'true';

        // Sort the rows, detecting numeric values if possible.
        rowsToSort.sort((a, b) => {
            const aContent = a.children[headerIndex].textContent.toLowerCase();
            const bContent = b.children[headerIndex].textContent.toLowerCase();
            const numericA = Number(aContent);
            const numericB = Number(bContent);

            if (!Number.isNaN(numericA) && !Number.isNaN(numericB)) {
                return invert ? numericA - numericB : numericB - numericA;
            }

            return aContent === bContent ? 0 : (aContent < bContent ? (invert ? 1 : -1) : (invert ? -1 : 1));
        });

        // Re-append the rows in order
        for (const row of rowsToSort) {
            tbody.appendChild(row);
        }

        // Update the sorted status for later possible inversion of sort.
        headerCell.dataset.sorted = invert ? 'false' : 'true';
    }
</script>
@ssddanbrown commented on GitHub (Dec 13, 2022): Here's my no-library take for **in-WYSIWYG-editor** table sorting. Allows sorting via double clicking column headers. Supports inverse sort on re-sort of same column. Lightly tested on v22.11, Might have side-affects. Just needs to be added to the "Custom HTML Head Content" customization setting: ```html <script> // Hook into the WYSIWYG editor setup event and add our logic once loaded window.addEventListener('editor-tinymce::setup', event => { const editor = event.detail.editor; setupTableSort(editor); }); // Setup the required event handler, listening for double-click on table cells. function setupTableSort(editor) { editor.on('dblclick', event => { const target = event.target; const parentHeader = target.closest('table tr:first-child td, table tr:first-child th'); if (parentHeader) { // Sort out table within a transaction so this can be undone in the editor if required. editor.undoManager.transact(() => { sortTable(parentHeader, editor); }); } }); } // Sort the parent table of the given header cell that was clicked. function sortTable(headerCell) { const table = headerCell.closest('table'); // Exit if the table has a header row but the clicked cell was not part of that header if (table.querySelector('thead') && headerCell.closest('thead') === null) { return; } const headerRow = headerCell.parentNode; const headerIndex = [...headerRow.children].indexOf(headerCell); const tbody = table.querySelector('tbody'); const rowsToSort = [...table.querySelectorAll('tbody tr')].filter(tr => tr !== headerRow); const invert = headerCell.dataset.sorted === 'true'; // Sort the rows, detecting numeric values if possible. rowsToSort.sort((a, b) => { const aContent = a.children[headerIndex].textContent.toLowerCase(); const bContent = b.children[headerIndex].textContent.toLowerCase(); const numericA = Number(aContent); const numericB = Number(bContent); if (!Number.isNaN(numericA) && !Number.isNaN(numericB)) { return invert ? numericA - numericB : numericB - numericA; } return aContent === bContent ? 0 : (aContent < bContent ? (invert ? 1 : -1) : (invert ? -1 : 1)); }); // Re-append the rows in order for (const row of rowsToSort) { tbody.appendChild(row); } // Update the sorted status for later possible inversion of sort. headerCell.dataset.sorted = invert ? 'false' : 'true'; } </script> ```
Author
Owner

@Coros commented on GitHub (Jul 18, 2023):

Does anyone have the solution from @crpb working? I've obtained the jquery and datatable files from datatables.net. I've got an alias in Apache for a /localjs path. I've added the header script provided to BookStack. The browser console reports: uncaught sortable: el must be an htmlelement

I assume based on the header script I needed to add a table ID of bkmrk-sorted-table but that didn't help either.

@Coros commented on GitHub (Jul 18, 2023): Does anyone have the solution from @crpb working? I've obtained the jquery and datatable files from datatables.net. I've got an alias in Apache for a /localjs path. I've added the header script provided to BookStack. The browser console reports: `uncaught sortable: el must be an htmlelement` I assume based on the header script I needed to add a table ID of bkmrk-sorted-table but that didn't help either.
Author
Owner

@crpb commented on GitHub (Jul 19, 2023):

@Coros that datatables-hack broke here with the release in may and nobody "cried" enough to make it working again since then :P.
I think it was v23.05 - maybe this helps to determine where it got mangled.

I currently implemented https://github.com/BookStackApp/BookStack/issues/1518#issuecomment-1349568398 which worked since then without any issues but of course it is "something different".

@crpb commented on GitHub (Jul 19, 2023): @Coros that datatables-hack broke here with the release in may and nobody "cried" enough to make it working again since then :P. I think it was [v23.05](https://github.com/BookStackApp/BookStack/releases/tag/v23.05) - maybe this helps to determine where it got mangled. I currently implemented https://github.com/BookStackApp/BookStack/issues/1518#issuecomment-1349568398 which worked since then without any issues but of course it is "something different".
Author
Owner

@Coros commented on GitHub (Jul 20, 2023):

@crpb Thanks for the update. I would really like the tables to be sorted by the user/viewer. We're migrating away from Confluence and have some rather large tables that help to have on demand sorting. I implemented the WYSIWIG editor change but it didn't seem to sort properly. It only reversed the order rather than sorting by alpha/num. I'll give it another try.

edit: I had a couple blank rows in the table and I think that caused problems. After removing those, it does sort properly in the editor.

@Coros commented on GitHub (Jul 20, 2023): @crpb Thanks for the update. I would really like the tables to be sorted by the user/viewer. We're migrating away from Confluence and have some rather large tables that help to have on demand sorting. I implemented the WYSIWIG editor change but it didn't seem to sort properly. It only reversed the order rather than sorting by alpha/num. I'll give it another try. edit: I had a couple blank rows in the table and I think that caused problems. After removing those, it does sort properly in the editor.
Author
Owner

@TineUser commented on GitHub (Jul 25, 2023):

Here's my no-library take for in-WYSIWYG-editor table sorting. Allows sorting via double clicking column headers. Supports inverse sort on re-sort of same column. Lightly tested on v22.11, Might have side-affects. Just needs to be added to the "Custom HTML Head Content" customization setting...

@ssddanbrown: Not bad, but this allows sorting only for editors. It would be great if viewers can sort the tables, too.

@TineUser commented on GitHub (Jul 25, 2023): > Here's my no-library take for **in-WYSIWYG-editor** table sorting. Allows sorting via double clicking column headers. Supports inverse sort on re-sort of same column. Lightly tested on v22.11, Might have side-affects. Just needs to be added to the "Custom HTML Head Content" customization setting... @ssddanbrown: Not bad, but this allows sorting only for editors. It would be great if viewers can sort the tables, too.
Author
Owner

@mlbarrow commented on GitHub (Sep 3, 2023):

@ssddanbrown I'm so amped by your table sort hack above that I had to make this post. Thanks for this. I'll also take this opportunity to 👍 a feature to do this out of the box. I'd prioritize the feature for those with editor permissions (I'm biased), but also see the value for viewers.

A big 💯 for your work on Bookstack, too!!!

@mlbarrow commented on GitHub (Sep 3, 2023): @ssddanbrown I'm so amped by your table sort hack above that I had to make this post. Thanks for this. I'll also take this opportunity to 👍 a feature to do this out of the box. I'd prioritize the feature for those with editor permissions (I'm biased), but also see the value for viewers. A big 💯 for your work on Bookstack, too!!!
Author
Owner

@rnldnkp commented on GitHub (Oct 13, 2024):

Here's my no-library take for in-WYSIWYG-editor table sorting. Allows sorting via double clicking column headers. Supports inverse sort on re-sort of same column. Lightly tested on v22.11, Might have side-affects. Just needs to be added to the "Custom HTML Head Content" customization setting:

@ssddanbrown Very nice! Still works as of today. Should be available by default.
Any change you know how to fix this for the 'viewer' mode, which allows live (stateless) sorting? Currently only works in Editing mode.

@rnldnkp commented on GitHub (Oct 13, 2024): > Here's my no-library take for **in-WYSIWYG-editor** table sorting. Allows sorting via double clicking column headers. Supports inverse sort on re-sort of same column. Lightly tested on v22.11, Might have side-affects. Just needs to be added to the "Custom HTML Head Content" customization setting: @ssddanbrown Very nice! Still works as of today. Should be available by default. Any change you know how to fix this for the 'viewer' mode, which allows live (stateless) sorting? Currently only works in Editing mode.
Author
Owner

@alexschomb commented on GitHub (Oct 17, 2024):

I had the same request by a customer for manual sorting of tables in the frontend. Thanks to the help of modern AI technology and my humble webdev experience, I was able to develop a working solution that even implements up/down arrows next to the column headers. It will work in both light & dark mode and will make use of the CSS variables that can be defined in the customization settings. You are welcome to use it or even implement it into BookStack.

Just add the following to the "Custom HTML Head Content" in the customization settings:

<style>
    .sort-icon {
        margin-left: 5px;
        font-size: 0.8em;
        opacity: 0.6;
        float: right;
    }
    .sort-icon.active {
        color: var(--color-primary);
        opacity: 1;
    }
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
    const pageContent = document.querySelector(".page-content");
    if (!pageContent) return;

    const tables = pageContent.querySelectorAll("table");
    
    // Function to clean up unnecessary <br> tags inside or after <strong> tags
    function removeTrailingBreaks() {
        const strongElements = pageContent.querySelectorAll("strong");
        strongElements.forEach(strong => {
            if (strong.nextSibling && strong.nextSibling.nodeName === "BR") {
                strong.parentNode.removeChild(strong.nextSibling);
            }
            if (strong.lastChild && strong.lastChild.nodeName === "BR") {
                strong.removeChild(strong.lastChild);
            }
        });
    }

    // Remove trailing <br> elements before adding sort icons
    removeTrailingBreaks();

    tables.forEach(table => {
        const headers = table.querySelectorAll("thead tr td");
        headers.forEach((header, index) => {
            // Add initial sorting icon (inactive up arrow)
            const sortIcon = document.createElement("span");
            sortIcon.classList.add("sort-icon");
            sortIcon.innerHTML = "\u25B2"; // Up arrow (inactive by default)
            header.appendChild(sortIcon);

            header.style.cursor = "pointer";
            header.dataset.sortOrder = ""; // No sorting initially

            header.addEventListener("click", () => {
                const currentSortOrder = header.dataset.sortOrder === "asc" ? "desc" : "asc";
                header.dataset.sortOrder = currentSortOrder;

                // Reset the sort icons for all headers
                headers.forEach(h => {
                    const icon = h.querySelector(".sort-icon");
                    if (icon) {
                        icon.classList.remove("active");
                        icon.style.opacity = "0.6";
                        icon.innerHTML = "\u25B2"; // Reset to inactive up arrow
                    }
                    h.dataset.sortOrder = "";
                });

                // Set the active sort icon for the clicked header
                if (currentSortOrder === "asc") {
                    sortIcon.classList.add("active");
                    sortIcon.innerHTML = "\u25B2"; // Up arrow for ascending
                    sortIcon.style.opacity = "1";
                } else {
                    sortIcon.classList.add("active");
                    sortIcon.innerHTML = "\u25BC"; // Down arrow for descending
                    sortIcon.style.opacity = "1";
                }

                header.dataset.sortOrder = currentSortOrder;

                // Sort the table
                sortTable(table, index, currentSortOrder);
            });
        });
    });

    function sortTable(table, columnIndex, sortOrder) {
        const rows = Array.from(table.querySelectorAll("tbody tr"));
        const sortedRows = rows.sort((a, b) => {
            const cellA = a.children[columnIndex]?.innerText.toLowerCase() || "";
            const cellB = b.children[columnIndex]?.innerText.toLowerCase() || "";
            let comparison = 0;
            
            if (!isNaN(parseFloat(cellA)) && !isNaN(parseFloat(cellB))) {
                comparison = parseFloat(cellA) - parseFloat(cellB);
            } else {
                comparison = cellA.localeCompare(cellB);
            }

            return sortOrder === "asc" ? comparison : -comparison;
        });

        const tbody = table.querySelector("tbody");
        sortedRows.forEach(row => tbody.appendChild(row));
    }
});
</script>
@alexschomb commented on GitHub (Oct 17, 2024): I had the same request by a customer for manual sorting of tables in the frontend. Thanks to the help of modern AI technology and my humble webdev experience, I was able to develop a working solution that even implements up/down arrows next to the column headers. It will work in both light & dark mode and will make use of the CSS variables that can be defined in the customization settings. You are welcome to use it or even implement it into BookStack. Just add the following to the "Custom HTML Head Content" in the customization settings: ```HTML <style> .sort-icon { margin-left: 5px; font-size: 0.8em; opacity: 0.6; float: right; } .sort-icon.active { color: var(--color-primary); opacity: 1; } </style> <script> document.addEventListener("DOMContentLoaded", function() { const pageContent = document.querySelector(".page-content"); if (!pageContent) return; const tables = pageContent.querySelectorAll("table"); // Function to clean up unnecessary <br> tags inside or after <strong> tags function removeTrailingBreaks() { const strongElements = pageContent.querySelectorAll("strong"); strongElements.forEach(strong => { if (strong.nextSibling && strong.nextSibling.nodeName === "BR") { strong.parentNode.removeChild(strong.nextSibling); } if (strong.lastChild && strong.lastChild.nodeName === "BR") { strong.removeChild(strong.lastChild); } }); } // Remove trailing <br> elements before adding sort icons removeTrailingBreaks(); tables.forEach(table => { const headers = table.querySelectorAll("thead tr td"); headers.forEach((header, index) => { // Add initial sorting icon (inactive up arrow) const sortIcon = document.createElement("span"); sortIcon.classList.add("sort-icon"); sortIcon.innerHTML = "\u25B2"; // Up arrow (inactive by default) header.appendChild(sortIcon); header.style.cursor = "pointer"; header.dataset.sortOrder = ""; // No sorting initially header.addEventListener("click", () => { const currentSortOrder = header.dataset.sortOrder === "asc" ? "desc" : "asc"; header.dataset.sortOrder = currentSortOrder; // Reset the sort icons for all headers headers.forEach(h => { const icon = h.querySelector(".sort-icon"); if (icon) { icon.classList.remove("active"); icon.style.opacity = "0.6"; icon.innerHTML = "\u25B2"; // Reset to inactive up arrow } h.dataset.sortOrder = ""; }); // Set the active sort icon for the clicked header if (currentSortOrder === "asc") { sortIcon.classList.add("active"); sortIcon.innerHTML = "\u25B2"; // Up arrow for ascending sortIcon.style.opacity = "1"; } else { sortIcon.classList.add("active"); sortIcon.innerHTML = "\u25BC"; // Down arrow for descending sortIcon.style.opacity = "1"; } header.dataset.sortOrder = currentSortOrder; // Sort the table sortTable(table, index, currentSortOrder); }); }); }); function sortTable(table, columnIndex, sortOrder) { const rows = Array.from(table.querySelectorAll("tbody tr")); const sortedRows = rows.sort((a, b) => { const cellA = a.children[columnIndex]?.innerText.toLowerCase() || ""; const cellB = b.children[columnIndex]?.innerText.toLowerCase() || ""; let comparison = 0; if (!isNaN(parseFloat(cellA)) && !isNaN(parseFloat(cellB))) { comparison = parseFloat(cellA) - parseFloat(cellB); } else { comparison = cellA.localeCompare(cellB); } return sortOrder === "asc" ? comparison : -comparison; }); const tbody = table.querySelector("tbody"); sortedRows.forEach(row => tbody.appendChild(row)); } }); </script> ```
Author
Owner

@rnldnkp commented on GitHub (Oct 17, 2024):

Oh wow, @alexschomb that worked instantly!
Many many thanks! Such a feature (both front and backend) should be available by default. Maybe @ssddanbrown could adopt both?

@rnldnkp commented on GitHub (Oct 17, 2024): Oh wow, @alexschomb that worked instantly! Many many thanks! Such a feature (both front and backend) should be available by default. Maybe @ssddanbrown could adopt both?
Author
Owner

@alexschomb commented on GitHub (Oct 17, 2024):

@rnldnkp Using both scripts together won't work because they will interfere due to similar function names. I took the liberty to advance my script so that it should be working now in the backend editor as well. There is one little bug that I couldn't solve that causes the sorting state of a column header to be saved as well. Actually, this might even be called a feature, but I think it might be bad practice and should be solved. I did some tests with the TinyMCE event editor.on('SaveContent', function(e) {...}), but somehow the state of the sorting icon was still saved to the database. Maybe there is a different event to listen to. Perhaps @ssddanbrown has a better idea.

By the way, the script also has a new feature to reset sorting when clicking on the same column header for the third time.

<style>
    .sort-icon {
        margin-left: 5px;
        font-size: 0.8em;
        opacity: 0.6;
        float: right;
    }
    .sort-icon.active {
        color: var(--color-primary);
        opacity: 1;
    }
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
    setupSorting();

    // Hook into the WYSIWYG editor setup event and add our logic once loaded
    window.addEventListener('editor-tinymce::setup', event => {
        const editor = event.detail.editor;

        editor.on('init', () => {
            const iframe = document.getElementById('html-editor_ifr');
            if (iframe) {
                const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
                const iframeBody = iframeDoc.getElementById('tinymce');
                if (iframeBody) {
                    setupSorting(iframeBody, true, editor);
                }
            }
        });
    });

    function setupSorting(context = document, isBackend = false, editor = null) {
        const tables = context.querySelectorAll("table");

        // Remove trailing <br> elements before adding sort icons
        removeTrailingBreaks(context);

        tables.forEach(table => {
            const headers = table.querySelectorAll("thead tr td");
            headers.forEach((header, index) => {
                if (!header.querySelector(".sort-icon")) {
                    // Add initial sorting icon (inactive up arrow)
                    const sortIcon = document.createElement("span");
                    sortIcon.classList.add("sort-icon");
                    sortIcon.innerHTML = "\u25B2"; // Up arrow (inactive by default)
                    header.appendChild(sortIcon);
                }

                header.style.cursor = "pointer";
                header.dataset.sortOrder = "none"; // Start with no sorting

                header.addEventListener("click", () => {
                    let currentSortOrder = header.dataset.sortOrder;

                    // Transition sort order: none -> asc -> desc -> none
                    switch (currentSortOrder) {
                        case "none":
                            currentSortOrder = "asc";
                            break;
                        case "asc":
                            currentSortOrder = "desc";
                            break;
                        case "desc":
                            currentSortOrder = "none";
                            break;
                    }

                    // Reset sorting for all headers in the table
                    headers.forEach(h => {
                        const icon = h.querySelector(".sort-icon");
                        if (icon) {
                            icon.classList.remove("active");
                            icon.style.opacity = "0.6";
                            icon.innerHTML = "\u25B2"; // Default to inactive up arrow
                        }
                        h.dataset.sortOrder = "none";
                    });

                    // Apply sorting state to the clicked header
                    header.dataset.sortOrder = currentSortOrder;
                    const sortIcon = header.querySelector(".sort-icon");

                    if (currentSortOrder === "asc") {
                        sortIcon.classList.add("active");
                        sortIcon.innerHTML = "\u25B2"; // Active up arrow for ascending
                        sortIcon.style.opacity = "1";
                        sortTable(table, index, "asc");
                    } else if (currentSortOrder === "desc") {
                        sortIcon.classList.add("active");
                        sortIcon.innerHTML = "\u25BC"; // Active down arrow for descending
                        sortIcon.style.opacity = "1";
                        sortTable(table, index, "desc");
                    } else if (currentSortOrder === "none") {
                        resetTableOrder(table);
                    }

                    // If in backend, reflect changes to editor
                    if (isBackend && editor) {
                        editor.undoManager.add();
                    }
                });
            });

            // Store original order of rows for unsorting functionality
            storeOriginalOrder(table);
        });
    }

    // Function to clean up unnecessary <br> tags inside or after <strong> tags
    function removeTrailingBreaks(context) {
        const strongElements = context.querySelectorAll("strong");
        strongElements.forEach(strong => {
            if (strong.nextSibling && strong.nextSibling.nodeName === "BR") {
                strong.parentNode.removeChild(strong.nextSibling);
            }
            if (strong.lastChild && strong.lastChild.nodeName === "BR") {
                strong.removeChild(strong.lastChild);
            }
        });
    }

    // Sort table rows
    function sortTable(table, columnIndex, sortOrder) {
        const rows = Array.from(table.querySelectorAll("tbody tr"));
        const sortedRows = rows.sort((a, b) => {
            const cellA = a.children[columnIndex]?.innerText.toLowerCase() || "";
            const cellB = b.children[columnIndex]?.innerText.toLowerCase() || "";
            let comparison = 0;
            
            if (!isNaN(parseFloat(cellA)) && !isNaN(parseFloat(cellB))) {
                comparison = parseFloat(cellA) - parseFloat(cellB);
            } else {
                comparison = cellA.localeCompare(cellB);
            }

            return sortOrder === "asc" ? comparison : -comparison;
        });

        const tbody = table.querySelector("tbody");
        sortedRows.forEach(row => tbody.appendChild(row));
    }

    // Reset the table to its original order
    function resetTableOrder(table) {
        const rows = Array.from(table.querySelectorAll("tbody tr"));
        rows.sort((a, b) => parseInt(a.dataset.originalIndex) - parseInt(b.dataset.originalIndex));
        const tbody = table.querySelector("tbody");
        rows.forEach(row => tbody.appendChild(row));
    }

    // Store original order of rows for unsorting functionality
    function storeOriginalOrder(table) {
        const rows = table.querySelectorAll("tbody tr");
        rows.forEach((row, index) => {
            row.dataset.originalIndex = index;
        });
    }
});
</script>
@alexschomb commented on GitHub (Oct 17, 2024): @rnldnkp Using both scripts together won't work because they will interfere due to similar function names. I took the liberty to advance my script so that it should be working now in the backend editor as well. There is one little bug that I couldn't solve that causes the sorting state of a column header to be saved as well. Actually, this might even be called a feature, but I think it might be bad practice and should be solved. I did some tests with the TinyMCE event `editor.on('SaveContent', function(e) {...})`, but somehow the state of the sorting icon was still saved to the database. Maybe there is a different event to listen to. Perhaps @ssddanbrown has a better idea. By the way, the script also has a new feature to reset sorting when clicking on the same column header for the third time. ```HTML <style> .sort-icon { margin-left: 5px; font-size: 0.8em; opacity: 0.6; float: right; } .sort-icon.active { color: var(--color-primary); opacity: 1; } </style> <script> document.addEventListener("DOMContentLoaded", function() { setupSorting(); // Hook into the WYSIWYG editor setup event and add our logic once loaded window.addEventListener('editor-tinymce::setup', event => { const editor = event.detail.editor; editor.on('init', () => { const iframe = document.getElementById('html-editor_ifr'); if (iframe) { const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; const iframeBody = iframeDoc.getElementById('tinymce'); if (iframeBody) { setupSorting(iframeBody, true, editor); } } }); }); function setupSorting(context = document, isBackend = false, editor = null) { const tables = context.querySelectorAll("table"); // Remove trailing <br> elements before adding sort icons removeTrailingBreaks(context); tables.forEach(table => { const headers = table.querySelectorAll("thead tr td"); headers.forEach((header, index) => { if (!header.querySelector(".sort-icon")) { // Add initial sorting icon (inactive up arrow) const sortIcon = document.createElement("span"); sortIcon.classList.add("sort-icon"); sortIcon.innerHTML = "\u25B2"; // Up arrow (inactive by default) header.appendChild(sortIcon); } header.style.cursor = "pointer"; header.dataset.sortOrder = "none"; // Start with no sorting header.addEventListener("click", () => { let currentSortOrder = header.dataset.sortOrder; // Transition sort order: none -> asc -> desc -> none switch (currentSortOrder) { case "none": currentSortOrder = "asc"; break; case "asc": currentSortOrder = "desc"; break; case "desc": currentSortOrder = "none"; break; } // Reset sorting for all headers in the table headers.forEach(h => { const icon = h.querySelector(".sort-icon"); if (icon) { icon.classList.remove("active"); icon.style.opacity = "0.6"; icon.innerHTML = "\u25B2"; // Default to inactive up arrow } h.dataset.sortOrder = "none"; }); // Apply sorting state to the clicked header header.dataset.sortOrder = currentSortOrder; const sortIcon = header.querySelector(".sort-icon"); if (currentSortOrder === "asc") { sortIcon.classList.add("active"); sortIcon.innerHTML = "\u25B2"; // Active up arrow for ascending sortIcon.style.opacity = "1"; sortTable(table, index, "asc"); } else if (currentSortOrder === "desc") { sortIcon.classList.add("active"); sortIcon.innerHTML = "\u25BC"; // Active down arrow for descending sortIcon.style.opacity = "1"; sortTable(table, index, "desc"); } else if (currentSortOrder === "none") { resetTableOrder(table); } // If in backend, reflect changes to editor if (isBackend && editor) { editor.undoManager.add(); } }); }); // Store original order of rows for unsorting functionality storeOriginalOrder(table); }); } // Function to clean up unnecessary <br> tags inside or after <strong> tags function removeTrailingBreaks(context) { const strongElements = context.querySelectorAll("strong"); strongElements.forEach(strong => { if (strong.nextSibling && strong.nextSibling.nodeName === "BR") { strong.parentNode.removeChild(strong.nextSibling); } if (strong.lastChild && strong.lastChild.nodeName === "BR") { strong.removeChild(strong.lastChild); } }); } // Sort table rows function sortTable(table, columnIndex, sortOrder) { const rows = Array.from(table.querySelectorAll("tbody tr")); const sortedRows = rows.sort((a, b) => { const cellA = a.children[columnIndex]?.innerText.toLowerCase() || ""; const cellB = b.children[columnIndex]?.innerText.toLowerCase() || ""; let comparison = 0; if (!isNaN(parseFloat(cellA)) && !isNaN(parseFloat(cellB))) { comparison = parseFloat(cellA) - parseFloat(cellB); } else { comparison = cellA.localeCompare(cellB); } return sortOrder === "asc" ? comparison : -comparison; }); const tbody = table.querySelector("tbody"); sortedRows.forEach(row => tbody.appendChild(row)); } // Reset the table to its original order function resetTableOrder(table) { const rows = Array.from(table.querySelectorAll("tbody tr")); rows.sort((a, b) => parseInt(a.dataset.originalIndex) - parseInt(b.dataset.originalIndex)); const tbody = table.querySelector("tbody"); rows.forEach(row => tbody.appendChild(row)); } // Store original order of rows for unsorting functionality function storeOriginalOrder(table) { const rows = table.querySelectorAll("tbody tr"); rows.forEach((row, index) => { row.dataset.originalIndex = index; }); } }); </script> ```
Author
Owner

@rnldnkp commented on GitHub (Oct 17, 2024):

Nice :)

Somehow I can use both by the way. Currently the backend is de first script (click header), frontend is the second (with arrows).
One uniform solution for both is better of course!

@rnldnkp commented on GitHub (Oct 17, 2024): Nice :) Somehow I can use both by the way. Currently the backend is de first script (click header), frontend is the second (with arrows). One uniform solution for both is better of course!
Author
Owner

@TineUser commented on GitHub (Oct 18, 2024):

The new head content sounds great but doesn't work for me. Does this only work with newer BookStack releases? I'm using BookStack v24.02.3 because I can't upgrade PHP at the moment.

So I reverted back to this head content https://github.com/BookStackApp/BookStack/issues/1518#issuecomment-1349568398 which is working fine but only in the backend editor.

@TineUser commented on GitHub (Oct 18, 2024): The new head content sounds great but doesn't work for me. Does this only work with newer BookStack releases? I'm using BookStack v24.02.3 because I can't upgrade PHP at the moment. So I reverted back to this head content https://github.com/BookStackApp/BookStack/issues/1518#issuecomment-1349568398 which is working fine but only in the backend editor.
Author
Owner

@alexschomb commented on GitHub (Oct 18, 2024):

The new head content sounds great but doesn't work for me. Does this only work with newer BookStack releases? I'm using BookStack v24.02.3 because I can't upgrade PHP at the moment.

Sorry, I only tested it with the latest Docker version of BookStack. Can't test older versions right now, but possibly the CSS selector is the reason. @ssddanbrown used table tr:first-child td, table tr:first-child th which is less specific than thead tr td (table is implied by earlier lines) of my code. My code will only work with table headers that were defined as such in the TinyMCE Editor. It's likely that older versions of BookStack didn't support table headers and a more general approach (the first row of all tables) is required. Feel free to replace my selector with tr:first-child td, tr:first-child th, it might be working.

@alexschomb commented on GitHub (Oct 18, 2024): > The new head content sounds great but doesn't work for me. Does this only work with newer BookStack releases? I'm using BookStack v24.02.3 because I can't upgrade PHP at the moment. Sorry, I only tested it with the latest Docker version of BookStack. Can't test older versions right now, but possibly the CSS selector is the reason. @ssddanbrown used `table tr:first-child td, table tr:first-child th` which is less specific than `thead tr td` (`table` is implied by earlier lines) of my code. My code will only work with table headers that were defined as such in the TinyMCE Editor. It's likely that older versions of BookStack didn't support table headers and a more general approach (the first row of all tables) is required. Feel free to replace my selector with `tr:first-child td, tr:first-child th`, it might be working.
Author
Owner

@TineUser commented on GitHub (Oct 19, 2024):

Sorry, I don't understand what to replace exactly. I'm not a developer so I don't have experience in such coding.

@TineUser commented on GitHub (Oct 19, 2024): Sorry, I don't understand what to replace exactly. I'm not a developer so I don't have experience in such coding.
Author
Owner

@alexschomb commented on GitHub (Oct 19, 2024):

@TineUser in my code above replace this line of code from

            const headers = table.querySelectorAll("thead tr td");

to this

            const headers = table.querySelectorAll("tr:first-child td, tr:first-child th");
@alexschomb commented on GitHub (Oct 19, 2024): @TineUser in my code above replace this line of code from ```HTML const headers = table.querySelectorAll("thead tr td"); ``` to this ```HTML const headers = table.querySelectorAll("tr:first-child td, tr:first-child th"); ```
Author
Owner

@TineUser commented on GitHub (Oct 19, 2024):

@alexschomb: Thank you. This works now in editor and viewer mode but it also sorts the column headers.

@TineUser commented on GitHub (Oct 19, 2024): @alexschomb: Thank you. This works now in editor and viewer mode but it also sorts the column headers.
Author
Owner

@TineUser commented on GitHub (Oct 19, 2024):

@alexschomb: OK, now I understand how your script is working. You have to define a header row in the table for the sorting feature. Then it works as you scripted it.

But there's one strange thing: When configuring the first row as header there will be inserted one more column which is empty but has the sorting icon.

image

This is in editor and viewer mode.

@TineUser commented on GitHub (Oct 19, 2024): @alexschomb: OK, now I understand how your script is working. You have to define a header row in the table for the sorting feature. Then it works as you scripted it. But there's one strange thing: When configuring the first row as header there will be inserted one more column which is empty but has the sorting icon. ![image](https://github.com/user-attachments/assets/ad58f115-b854-462d-bfa6-5f540b6de1c6) This is in editor and viewer mode.
Author
Owner

@alexschomb commented on GitHub (Oct 20, 2024):

@TineUser thanks for the feedback. Unfortunately, I can't test with other versions at the moment. I can't reproduce the issue with the newest version.

@alexschomb commented on GitHub (Oct 20, 2024): @TineUser thanks for the feedback. Unfortunately, I can't test with other versions at the moment. I can't reproduce the issue with the newest version.
Author
Owner

@GamerClassN7 commented on GitHub (Jul 1, 2025):

Hi @alexschomb,
any idea how to fix miss aligment of arrows ?

Image

for anyone to make it work with new version change line 45 to
const headers = table.querySelectorAll("thead tr:first-child td, thead tr:first-child th");

@GamerClassN7 commented on GitHub (Jul 1, 2025): Hi @alexschomb, any idea how to fix miss aligment of arrows ? ![Image](https://github.com/user-attachments/assets/3b5aca75-ea10-44ba-8ff1-acb927171914) for anyone to make it work with new version change line 45 to ` const headers = table.querySelectorAll("thead tr:first-child td, thead tr:first-child th");`
Author
Owner

@GamerClassN7 commented on GitHub (Jul 9, 2025):

Hello,

@ssddanbrown could this hack be incorporated and refined as an official bookstack hack since when using a lot of tables this feature is awesome 👍 I try to refine it myself, but for some reasons I was not able to use the new version of BS, mostly the styles.

Thank you in advance for your opinion!!!

Also thanks again for this super useful self hosted gem 💎

@GamerClassN7 commented on GitHub (Jul 9, 2025): Hello, @ssddanbrown could this hack be incorporated and refined as an official bookstack hack since when using a lot of tables this feature is awesome 👍 I try to refine it myself, but for some reasons I was not able to use the new version of BS, mostly the styles. Thank you in advance for your opinion!!! Also thanks again for this super useful self hosted gem 💎
Author
Owner

@ssddanbrown commented on GitHub (Jul 9, 2025):

@GamerClassN7 Maybe in the future but I'm putting a pause on new editor-based hacks until we're further along the process of switching across to the new editor.

@ssddanbrown commented on GitHub (Jul 9, 2025): @GamerClassN7 Maybe in the future but I'm putting a pause on new editor-based hacks until we're further along the process of switching across to the new editor.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/BookStack#1244