Hacks & Customizations
These hacks are unsupported customizations meant as unofficial workarounds.
They can cause instability, introduce issues and may conflict with future updates. Apply at your own risk!

WYSIWYG Editor Footnotes

  • Author: @ssddanbrown
  • Created: 3rd May 2023
  • Updated: 3rd May 2023
  • Last Tested On: v23.05

This hack adds some level of “footnote” support to the WYSIWYG editor. A new “Footnote” button is added to the toolbar, next to the “Italic” button, that allows you to insert a new footnote reference. Footnotes will automatically be listed at the bottom of the page content. The reference numbering is automatic, chronologically from page top to bottom. New references will change existing numbering if inserted before.

This hack provides significant examples of TinyMCE (The library used for the WYSIWYG) content manipulation and extension. The code is heavily commented to assist as a helpful example. For significant alterations, you’ll likely want to review the TinyMCE documentation to understand the full set of available capabilities and actions within the TinyMCE editor API.

Considerations

  • This heavily relies on internal methods of TinyMCE, which may change upon any BookStack release as we update the editor libraries.
  • All logic is within the WYSIWYG editor, and therefore you won’t get the same functionality via the API or other editors.
  • The syntax & code used likely won’t be cross-compatible with the markdown editor.
  • The footnotes list will be generated when content is saved from the editor, so is not updated live but should always be auto-updated before save.
  • This has been tested to some degree but there’s a reasonable chance of bugs or side affects, since there’s quite a lot going on here.
  • There’s a lot of custom code here. You could instead put this code (without the HTML <script> tags) in an external JavaScript file and then just use a single <script src="/path/to/file.js"></script> within the custom head setting.

Code

head.html
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
<script>
    // Take a footnote anchor and convert it to the HTML that would be expected
    // at the bottom of the page in the list of references.
    function footnoteToHtml(elem) {
        const newWrap = document.createElement('div');
        const newAnchor = document.createElement('a');
        const sup = document.createElement('sup');
        const text = document.createTextNode(' ' + elem.title.trim());
        sup.textContent = elem.textContent.trim();
        newAnchor.id = elem.getAttribute('href').replace('#', '');
        newAnchor.href = '#';
        newAnchor.append(sup);
        newWrap.append(newAnchor, text);
        return newWrap.outerHTML;
    }

    // Reset the numbering of all footnotes within the editor
    function resetFootnoteNumbering(editor) {
        const footnotes = editor.dom.select('a[href^="#bkmrk-footnote-"]');
        for (let i = 0; i < footnotes.length; i++) {
            const footnote = footnotes[i];
            const textEl = footnote.querySelector('sup') || footnote;
            textEl.textContent = String(i + 1);
        }
    }

    // Update the footnotes list at the bottom of the content.
    function updateFootnotes(editor) {
        // Filter out existing footnote blocks on parse
        const footnoteBlocks = editor.dom.select('body > div.footnotes');
        for (const blocks of footnoteBlocks) {
            blocks.remove();
        }

        // Gather our existing footnote references and return if nothing to add
        const footnotes = editor.dom.select('a[href^="#bkmrk-footnote-"]');
        if (footnotes.length === 0) {
            return;
        }

        // Build and append our footnote block
        resetFootnoteNumbering(editor);
        const footnoteHtml = [...footnotes].map(f => footnoteToHtml(f));
        editor.dom.add(editor.getBody(), 'div', {class: 'footnotes'}, '<hr/>' + footnoteHtml.join('\n'));
    }

    // Get the current selected footnote (if any)
    function getSelectedFootnote(editor) {
        return editor.selection.getNode().closest('a[href^="#bkmrk-footnote-"]');
    }

    // Insert a new footnote element within the editor at cursor position.
    function insertFootnote(editor, text) {
        const sup = editor.dom.create('sup', {}, '1');
        const anchor = editor.dom.create('a', {href: `#bkmrk-footnote-${Date.now()}`, title: text});
        anchor.append(sup);
        editor.selection.collapse(false);
        editor.insertContent(anchor.outerHTML + ' ');
    }

    function showFootnoteInsertDialog(editor) {
        const footnote = getSelectedFootnote(editor);

        // Show a custom form dialog window to edit the footnote text/label
        const dialog = editor.windowManager.open({
            title: 'Edit Footnote',
            body: {
                type: 'panel',
                items: [{type: 'input', name: 'text', label: 'Footnote Label/Text'}],
            },
            buttons: [
                {type: 'cancel', text: 'Cancel'},
                {type: 'submit', text: 'Save', primary: true},
            ],
            onSubmit(api) {
                // On submit update or insert a footnote element
                const {text} = api.getData();
                if (footnote) {
                    footnote.setAttribute('title', text);
                } else {
                    insertFootnote(editor, text);
                    editor.execCommand('RemoveFormat');
                }
                updateFootnotes(editor);
                api.close();
            },
        });

        if (footnote) {
            dialog.setData({text: footnote.getAttribute('title')});
        }
    }

    // Listen to pre-init event to customize TinyMCE config
    window.addEventListener('editor-tinymce::pre-init', event => {
        const tinyConfig = event.detail.config;
        // Add our custom footnote button to the toolbar
        tinyConfig.toolbar = tinyConfig.toolbar.replace('italic ', 'italic footnote ');
    });

    // Listen to setup event so we customize the editor.
    window.addEventListener('editor-tinymce::setup', event => {
        // Get a reference to the TinyMCE Editor instance
        const editor = event.detail.editor;

        // Add our custom footnote button
        editor.ui.registry.addToggleButton('footnote', {
            icon: 'footnote',
            tooltip: 'Add Footnote',
            active: false,
            onAction() {
                showFootnoteInsertDialog(editor);
            },
            onSetup(api) {
                editor.on('NodeChange', event => {
                    api.setActive(Boolean(getSelectedFootnote(editor)));
                });
            },
        });

        // Update footnotes before editor content is fetched
        editor.on('BeforeGetContent', () => {
            updateFootnotes(editor);
        });
    });
</script>

Request an Update

Hack not working on the latest version of BookStack?
You can request this hack to be updated & tested for a small one-time fee.
This helps keeps these hacks updated & maintained in a sustainable manner.


Latest Hacks