Repository: redimp/otterwiki

Issue:
Question: Adding more editor buttons as a PR? (blocks, quotes, spoiler etc.)
This is really just a question, not an actual issue ;)

I just started using An Otter Wiki, and really like it. One thing I would like to see is editor buttons for more (all?) missing syntax features. Especially for syntax heavy things (IMO) like special blocks.

I have done a few JavaScript projects in the past, and should be able to mostly code this myself. I just wanted to quickly check in with you, whether this would follow the vision you have for An Otter Wiki, and whether you would be interested in this as a PR?

Hints from issue comments:
@redimp: Hey @ttrafelet! A PR would be very welcome!

My wishlist for this feature:

- Narrow browser windows: To be able to use all the icons in narrow browser windows consider to group these drop-down menues like the table and the lists .. and hide them for really small screen widths using `class="hidden-sm-and-down"`
- Icons: I currently stick strongly to only icosn from `Font Awesome Free 5.15.4`. If possible pick the icons from there. For the sake of completeness: If this is not possible please make sure the icons have proper licenses.@ttrafelet: FYI: I am on it: https://github.com/ttrafelet/otterwiki/tree/menu-extension
I will create the PR once I think the code is at least worthy for a BETA test ;)@redimp: Closing this issues, since the PR is merged. 🥳 

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61a2ecc..460b374 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,16 +6,6 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
 <!-- insertion marker -->
-## [v2.9.4](https://github.com/redimp/otterwiki/releases/tag/v2.9.4) - 2025-02-27
-
-<small>[Compare with v2.9.3](https://github.com/redimp/otterwiki/compare/v2.9.3...v2.9.4)</small>
-
-### Bug Fixes
-
-- use full path for pages when displaying single commits, see #209 ([0bdd563](https://github.com/redimp/otterwiki/commit/0bdd5634491db2ef3fc17031c33b719c46e003d1)).
-- handle FileNotFoundError on updating the ftoc cache ([2e6c502](https://github.com/redimp/otterwiki/commit/2e6c50287cc8f2105d332f08863caac3d3f413aa)).
-- don't strip whitespace from code blocks #206 ([680f74a](https://github.com/redimp/otterwiki/commit/680f74a8ad30f02653f3dd473d3bc7c400e853aa)).
-
 ## [v2.9.3](https://github.com/redimp/otterwiki/releases/tag/v2.9.3) - 2025-02-12
 
 <small>[Compare with v2.9.2](https://github.com/redimp/otterwiki/compare/v2.9.2...v2.9.3)</small>
diff --git a/otterwiki/helper.py b/otterwiki/helper.py
index 06ab5d9..120970f 100644
--- a/otterwiki/helper.py
+++ b/otterwiki/helper.py
@@ -302,13 +302,7 @@ def sha256sum(s: str) -> str:
 
 def update_ftoc_cache(filename, ftoc, mtime=None):
     if mtime is None:
-        try:
-            mtime = storage.mtime(filename)
-        except FileNotFoundError as e:
-            app.logger.warning(
-                f"{e} while running update_ftoc_cache({filename})"
-            )
-            return
+        mtime = storage.mtime(filename)
     hash = sha256sum(f"ftoc://{filename}")
     value = json.dumps({"filename": filename, "ftoc": ftoc})
     # check if key exists in Cache
diff --git a/otterwiki/renderer.py b/otterwiki/renderer.py
index f6301e4..3e4fe25 100644
--- a/otterwiki/renderer.py
+++ b/otterwiki/renderer.py
@@ -272,23 +272,6 @@ class OtterwikiMdRenderer(mistune.HTMLRenderer):
         return rv
 
 
-class OtterwikiBlockParser(mistune.BlockParser):
-    INDENT_CODE = re.compile(r'((?:\n*)(?:(?: {4}| *\t)[^\n]+\n*)+)\n')
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-    def parse_indent_code(self, m, state):
-        """
-        Overrides mistunes rendering, since mistune catches one \n too much
-        """
-        raw = m.group(1)
-        text = mistune.block_parser.expand_leading_tab(raw)
-        code = mistune.block_parser._INDENT_CODE_TRIM.sub('', text)
-        code = code.lstrip('\n')
-        return self.tokenize_block_code(code, None, state)
-
-
 class OtterwikiInlineParser(mistune.InlineParser):
     def __init__(self, env, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -330,15 +313,12 @@ class OtterwikiRenderer:
             "config": config,
         }
         self.md_renderer = OtterwikiMdRenderer(env=self.env)
-        self.inline_parser = OtterwikiInlineParser(
+        self.inline_renderer = OtterwikiInlineParser(
             env=self.env, renderer=self.md_renderer, hard_wrap=False
         )
-        self.block_parser = OtterwikiBlockParser()
-
         self.mistune = OtterwikiMdParser(
             renderer=self.md_renderer,
-            inline=self.inline_parser,
-            block=self.block_parser,
+            inline=self.inline_renderer,
             plugins=[
                 plugin_table,
                 plugin_url,
@@ -365,12 +345,6 @@ class OtterwikiRenderer:
         self.md_renderer.reset_toc()
         # do the preparsing
         text = chain_hooks("renderer_markdown_preprocess", text)
-        # to avoid that preparsing removes the trailing newline and to be
-        # able to deal with manually comitted files that lack the trailing newline
-        # we add one in case it is missing
-        if text[-1] != "\n":
-            text += "\n"
-
         # add cursor position
         if cursor is not None:
             text_arr = text.splitlines()
diff --git a/otterwiki/renderer_plugins.py b/otterwiki/renderer_plugins.py
index f4fec34..daf0609 100644
--- a/otterwiki/renderer_plugins.py
+++ b/otterwiki/renderer_plugins.py
@@ -81,7 +81,6 @@ class mistunePluginFootnotes:
     def parse_footnote_item(self, block, k, refs, state):
         def_footnotes = state['def_footnotes']
         text = def_footnotes[k]
-        idx = list(def_footnotes.keys()).index(k) + 1
         stripped_text = text.strip()
         if '\n' not in stripped_text:
             children = [{'type': 'paragraph', 'text': stripped_text}]
@@ -101,7 +100,7 @@ class mistunePluginFootnotes:
         return {
             'type': 'footnote_item',
             'children': children,
-            'params': (k, idx, refs),
+            'params': (k, refs),
         }
 
     def md_footnotes_hook(self, md, result, state):
@@ -119,7 +118,9 @@ class mistunePluginFootnotes:
         return result + output
 
     def render_html_footnote_ref(self, key, index, fn):
-        return f'<sup class="footnote-ref" id="fnref-{fn}"><a href="#fn-{index}">{index}</a></sup>'
+        i = str(index)
+        html = '<sup class="footnote-ref" id="fnref-' + i + '">'
+        return html + '<a href="#fn-' + str(i) + '">' + str(i) + '</a></sup>'
 
     def render_html_footnotes(self, text):
         return (
@@ -128,7 +129,7 @@ class mistunePluginFootnotes:
             + '</ol>\n</section>\n'
         )
 
-    def render_html_footnote_item(self, text, key, kindex, refs):
+    def render_html_footnote_item(self, text, key, refs):
         if len(refs) == 1:
             back = (
                 '<a href="#fnref-'
@@ -150,11 +151,10 @@ class mistunePluginFootnotes:
 
         text = text.rstrip()
         if text.startswith('<p>'):
-            text = text[3:]
-        if text.endswith('</p>'):
-            text = text[:-4]
-        text = back + text
-        return '<li id="fn-' + str(kindex) + '">' + text + '</li>\n'
+            text = '<p>' + back + text[3:]
+        else:
+            text = back + text
+        return '<li id="fn-' + str(key) + '">' + text + '</li>\n'
 
     def __call__(self, md):
         md.inline.register_rule(
@@ -299,7 +299,8 @@ class mistunePluginFancyBlocks:
     FANCY_BLOCK_HEADER = re.compile(r'^#{1,5}\s*(.*)\n+')
 
     def parse_fancy_block(self, block, m, state):
-        text = m.group(4) or ""
+        # get text and the newline that has been eaten up
+        text = m.group(4) or "" + "\n"
         family = m.group(3).strip().lower()
 
         # find (and remove) the header from the text block
@@ -314,10 +315,6 @@ class mistunePluginFancyBlocks:
         rules.remove('axt_heading')
         rules.remove('setex_heading')
 
-        # add a trailing newline, so that the childen get rendered correctly
-        if text[-1] != "\n":
-            text += "\n"
-
         children = block.parse(text, state, rules)
         if not isinstance(children, list):
             children = [children]
@@ -373,6 +370,8 @@ class mistunePluginSpoiler:
         # the syntax >!
         text = self.SPOILER_LEADING.sub('', text)
 
+        text = text.strip()
+
         children = block.parse(text, state)
         if not isinstance(children, list):
             children = [children]
@@ -427,9 +426,8 @@ class mistunePluginFold:
             header = header.group(1)
             text = self.FOLD_BLOCK_HEADER.sub('', text, 1)
 
-        # add a trailing newline, so that the childen get rendered correctly
-        if text[-1] != "\n":
-            text += "\n"
+        # clean up trailing spaces
+        text = "\n".join([x.rstrip() for x in text.strip().splitlines()])
 
         children = block.parse(text, state)
         if not isinstance(children, list):
@@ -537,6 +535,8 @@ class mistunePluginAlerts:
         # syntax from the beginning of each line>
         text = self.ALERT_LEADING.sub('', text)
 
+        text = text.strip()
+
         children = block.parse(text, state)
         if not isinstance(children, list):
             children = [children]
diff --git a/otterwiki/server.py b/otterwiki/server.py
index ebc2338..2362e2a 100644
--- a/otterwiki/server.py
+++ b/otterwiki/server.py
@@ -56,7 +56,7 @@ app.config.update(
     SIDEBAR_SHORTCUTS="home pageindex createpage",
     ROBOTS_TXT="allow",
     WIKILINK_STYLE="",
-    MAX_FORM_MEMORY_SIZE=1_000_000,
+    MAX_FORM_MEMORY_SIZE=1_000_0000,
 )
 app.config.from_envvar("OTTERWIKI_SETTINGS", silent=True)
 
diff --git a/otterwiki/static/css/otterwiki.css b/otterwiki/static/css/otterwiki.css
index 490ef9a..d9e5ac4 100644
--- a/otterwiki/static/css/otterwiki.css
+++ b/otterwiki/static/css/otterwiki.css
@@ -106,6 +106,10 @@ pre,
 button.copy-to-clipboard {
 }
 
+.page > .alert > p {
+    margin-block-end: 0.2em;
+}
+
 .dark-mode code,
 .dark-mode pre,
 .dark-mode .code {
@@ -150,22 +154,6 @@ pre.mermaid {
 .quote-alert-caution { border-color: var(--danger-color); }
 .quote-alert-important { border-color: var(--indigo-color); }
 
-.page > .alert > h4.alert-heading,
-.dark-mode .page > .alert > h4.alert-heading {
-    font-size: 2.2rem;
-}
-
-.dark-mode .page > .alert > p,
-.page > .alert > p,
-.dark-mode .page > .alert > ul,
-.page > .alert > ul,
-.dark-mode .page > .alert > ol,
-.page > .alert > ol
-{
-    margin-block-start: 0.2em;
-    margin-block-end: 0.2em;
-}
-
 .revision-small {
     font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
     text-decoration: none;
diff --git a/otterwiki/static/js/otterwiki.js b/otterwiki/static/js/otterwiki.js
index 64b0087..73a07f3 100644
--- a/otterwiki/static/js/otterwiki.js
+++ b/otterwiki/static/js/otterwiki.js
@@ -1,5 +1,8 @@
 /* vim: set et sts=4 ts=4 sw=4 ai: */
 
+const lineEnd = 999999;
+const maxMultiLevels = 5;
+
 var otterwiki_editor = {
     /* simple functions */
     undo: function() {
@@ -11,6 +14,18 @@ var otterwiki_editor = {
         cm_editor.redo();
     },
     /* helper functions */
+    _setSelectedLines: function(lines) {
+        cm_editor.setSelection(
+            head = {
+                line: lines[0],
+                ch: 0
+            },
+            anchor = {
+                line: lines[lines.length - 1],
+                ch: lineEnd
+            }
+        );
+    },
     _getSelectedLines: function() {
         const [s, e] = [cm_editor.getCursor('start').line, cm_editor.getCursor('end').line]
         return [...Array(e - s + 1).keys()].map(i => i + s);
@@ -18,7 +33,7 @@ var otterwiki_editor = {
     _setLine: function(n, text) {
         cm_editor.replaceRange(
             text, { line: n, ch: 0, },
-                  { line: n, ch: 999999, }
+                  { line: n, ch: lineEnd, }
         );
     },
     _findWordAt: function(pos) {
@@ -98,6 +113,146 @@ var otterwiki_editor = {
 
         cm_editor.focus();
     },
+    _toggleMultilineBlock: function(syntaxStartChars, headerRegex = null, syntaxEndChars = null, placeholderContent = null) {
+        // This function prepends and appends a selection of one or more entire lines
+        // with a new line of "syntax_(start|end)_chars".
+
+        if (!(syntaxStartChars instanceof Array)) {
+            syntaxStartChars = [syntaxStartChars];
+        }
+        if (syntaxEndChars !== null && !(syntaxEndChars instanceof Array)) {
+            syntaxEndChars = [syntaxEndChars];
+        } else {
+            syntaxEndChars = syntaxStartChars;
+        }
+
+        if (placeholderContent !== null && !(placeholderContent instanceof Array)) {
+            placeholderContent = [placeholderContent];
+        } else if (placeholderContent === null) {
+            placeholderContent = [];
+        }
+
+        let selectedLines = otterwiki_editor._getSelectedLines();
+        if (selectedLines.length == 0) return;
+
+        const headerValue = syntaxStartChars[0];
+        const tailValue = syntaxEndChars[0];
+        let removeBlock = false;
+        const firstLine = cm_editor.getLine(selectedLines[0]);
+
+        // The user wants to insert the block on an empty line -> insert with place holder values
+        if (selectedLines.length == 1 && firstLine.match(/^$/)) { // user selected an empty line
+            const placeholderBlock = headerValue + "\n" + placeholderContent.join("\n") + "\n" + tailValue;
+            otterwiki_editor._setLine(selectedLines[0], placeholderBlock);
+
+            const firstLineNum = selectedLines[0];
+            selectedLines = []
+
+            // update selected lines to select only the placeholder content
+            for (let i = 1; i <= placeholderContent.length; i++) {
+                selectedLines.push(firstLineNum + i);
+            }
+            otterwiki_editor._setSelectedLines(selectedLines);
+
+            cm_editor.focus();
+            return
+        }
+
+        // Decide whether to add or remove the block, and whether the first line already
+        // has content. In the latter case, we have to insert an extra line for the header,
+        // because it needs to be on its own line.
+        // The first line already is a header =>  Determine whether we should remove, or change the header
+        if (headerRegex !== null && firstLine.match(headerRegex)) {
+
+            // Decide whether the first line is the current header line
+            let isHeader = false;
+            for (const startChar of syntaxStartChars) {
+                if (firstLine.trim() == startChar) {
+                    isHeader = true;
+                    break;
+                }
+            }
+
+            // the first line includes the header of the block -> remove it
+            if (isHeader) {
+                otterwiki_editor._setLine(selectedLines[0], "");
+                removeBlock = true;
+
+            } else { // the first line is a header, but a different one -> replace header and quit
+                otterwiki_editor._setLine(selectedLines[0], headerValue);
+                cm_editor.focus();
+                return;
+            }
+
+        } else if (!firstLine.trim().match("^$")) { // when the first selected line is not empty
+            otterwiki_editor._setLine(selectedLines[0], headerValue + "\n" + firstLine);
+
+            // set the selection so the newly added extra line is being included as well
+            selectedLines.push(selectedLines[selectedLines.length - 1] + 1);
+            otterwiki_editor._setSelectedLines(selectedLines);
+
+            selectedLines = otterwiki_editor._getSelectedLines();
+
+        } else {
+            otterwiki_editor._setLine(selectedLines[0], headerValue);
+        }
+
+        if (removeBlock) {
+            let endFound = false;
+            for (const ln of selectedLines.splice(1)) {
+                const lineValue = cm_editor.getLine(ln);
+
+                for (const endChar of syntaxEndChars) {
+                    if (lineValue.trim() == endChar) {
+                        otterwiki_editor._setLine(ln, "");
+                        endFound = true;
+                        break;
+                    }
+                }
+
+                if (endFound) break;
+            }
+
+            if (!endFound) {
+                // We only get here when the ending line was not found in the selection
+                // Find block end based on the syntax chars, abort if no end found
+                let blockEndLine = -1;
+                for (const endChar of syntaxEndChars) {
+                    blockEndLine = otterwiki_editor._findNextOccurenceLine(endChar)
+
+                    if (blockEndLine !== -1) {
+                        break;
+                    }
+                }
+
+                if (blockEndLine !== -1) {
+                    otterwiki_editor._setLine(blockEndLine, "");
+                } else {
+                    console.log("ERROR: No block end found! Remove manually.")
+                }
+            }
+
+        } else {
+
+            // Determine whether the last line is empty, and we may just set the block end
+            // or it already contains text, requiring us to add another line
+            const lastLineNum = selectedLines[selectedLines.length - 1]
+            const lastLine = cm_editor.getLine(lastLineNum);
+            let prefix = "";
+            if (!lastLine.trim().match(/^$/)) { // last selected line is not empty
+                prefix = lastLine + "\n";
+
+                // as we are adding a newline, we need to extend the selected lines by 1
+                selectedLines.push(selectedLines[selectedLines.length - 1] + 1);
+            }
+            otterwiki_editor._setLine(lastLineNum, prefix + tailValue);
+
+            // update the selection
+            otterwiki_editor._setSelectedLines(selectedLines);
+        }
+
+        cm_editor.focus();
+    },
     _toggleLines: function(line_prefix, line_re, token) {
         if (!(line_re instanceof Array)) {
             line_re = [line_re];
@@ -122,6 +277,91 @@ var otterwiki_editor = {
         }
         cm_editor.focus();
     },
+    _toggleLinesMultiLevel: function(indentChar, maxLevel=maxMultiLevels) {
+        if (!cm_editor) { return; }
+        for (const i of otterwiki_editor._getSelectedLines()) {
+            var line = cm_editor.doc.getLine(i);
+            var lineHLevel = line.search("[^" + indentChar + "]");
+            // In case of a line composed of only "indentChar"
+            if (lineHLevel < 0) { lineHLevel = line.length; }
+            if (lineHLevel == 0) {
+                otterwiki_editor._setLine(i, indentChar + " " + line);
+            } else if (lineHLevel / indentChar.length < maxLevel) {
+                // add a level
+                otterwiki_editor._setLine(i, indentChar + line);
+            } else {
+                // Remove all indentChars (not sure why the RegExp is needed. It won't work without, though)
+                otterwiki_editor._setLine(i, line.replace(new RegExp("^(?:" + indentChar + ")+\\s*"), ''));
+            }
+        }
+        cm_editor.focus();
+    },
+    _toggleAlert: function(header, placeholderContent = null) {
+        const headerValue = "[!" + header.toUpperCase() + "]";
+        const headerRegex = "^> \\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\]\\s*$";
+
+        if (placeholderContent !== null && !(placeholderContent instanceof Array)) {
+            placeholderContent = [placeholderContent];
+        } else if (placeholderContent === null) {
+            placeholderContent = [];
+        }
+
+        let selectedLines = otterwiki_editor._getSelectedLines();
+        if (selectedLines.length == 0) return;
+
+        const firstLine = cm_editor.getLine(selectedLines[0]);
+        let placeholderUsed = false;
+
+        // The user wants to insert the block on an empty line -> insert with place holder values
+        if (selectedLines.length == 1 && firstLine.match(/^$/)) { // user selected an empty line
+            placeholderUsed = true;
+
+            const firstLineNum = selectedLines[0];
+            otterwiki_editor._setLine(firstLineNum, "\n" + placeholderContent.join("\n"));
+
+            // update selected lines to select only the placeholder content
+            selectedLines = [];
+            for (let i = 0; i <= placeholderContent.length; i++) {
+                selectedLines.push(firstLineNum + i);
+            }
+            otterwiki_editor._setSelectedLines(selectedLines);
+        }
+
+        // Decide whether to add or remove the alert block, and whether the first line already
+        // has content. In the latter case, we have to insert an extra line for the alert
+        // header, because it needs to be on its own line.
+        // The first line already is a header => Determine whether we should unalert, or change the header
+        if (firstLine.match(headerRegex)) {
+
+            // the first line includes the header of the alert block -> "unalert"
+            if (firstLine.includes(headerValue)) {
+                otterwiki_editor._setLine(selectedLines[0], "> "); // keep the quote, as this is handled below
+
+            } else { // the first line is a header, but a different one -> replace header and quit
+                otterwiki_editor._setLine(selectedLines[0], firstLine.replace(/\[![A-Z]+]/, headerValue));
+                return;
+            }
+
+        } else if (!firstLine.match("^$")) { // when the first selected line is not empty
+            otterwiki_editor._setLine(selectedLines[0], headerValue + "\n" + firstLine);
+
+            // set the selection so the newly added extra line is being included as well
+            selectedLines.push(selectedLines[selectedLines.length - 1] + 1);
+            otterwiki_editor._setSelectedLines(selectedLines);
+
+        } else {
+            otterwiki_editor._setLine(selectedLines[0], headerValue);
+        }
+
+        // Now, simply quote all selected lines (the header part is what makes alerts special)
+        otterwiki_editor.quote(false);
+
+        // Finally, update the selection in case a placeholder was used
+        if (placeholderUsed) {
+            const lastLine = selectedLines[selectedLines.length - 1];
+            cm_editor.setSelection({ line: lastLine, ch: 2 }, { line: lastLine, ch: lineEnd })
+        }
+    },
     _getState: function(pos) {
         var cm = cm_editor;
         pos = pos || cm.getCursor('start');
@@ -165,26 +405,8 @@ var otterwiki_editor = {
     },
     /* formating functions */
     // header: increase the mardown header level till five remove afterwards
-    // TODO: refactor so that it works with quotes, too.
     header: function() {
-        if (!cm_editor) { return; }
-        for (const i of otterwiki_editor._getSelectedLines())
-        {
-            var line = cm_editor.doc.getLine(i);
-            var lineHLevel = line.search(/[^#]/);
-            // In case of a line composed of only "#"
-            if (lineHLevel < 0) { lineHLevel = line.length; }
-            if (lineHLevel == 0) {
-                otterwiki_editor._setLine(i, "# " + line);
-            } else if (lineHLevel < 5) {
-                // add a level
-                otterwiki_editor._setLine(i, "#" + line);
-            } else {
-                // Remove Heading
-                otterwiki_editor._setLine(i, line.replace(/^#+\s*/,''));
-            }
-        }
-        cm_editor.focus();
+        otterwiki_editor._toggleLinesMultiLevel("#");
     },
     bold: function() {
         otterwiki_editor._toggleBlock(["**","__"], "bold");
@@ -195,8 +417,90 @@ var otterwiki_editor = {
     strikethrough: function() {
         otterwiki_editor._toggleBlock("~~", "strikethrough");
     },
+    spoiler: function() {
+        const placeholder = "Hidden content";
+        const selectedLines = otterwiki_editor._getSelectedLines();
+        const firstLine = cm_editor.getLine(selectedLines[0]);
+        let placeholderUsed = false;
+
+        if (selectedLines.length == 1 && firstLine.match(/^\s*$/)) {
+            otterwiki_editor._setLine(selectedLines[0], placeholder);
+            placeholderUsed = true;
+        }
+
+        otterwiki_editor._toggleLinesMultiLevel(">!");
+
+        // Update selection in case a placeholder was used
+        // we can only do this after the spoiler prefix has been added
+        if (placeholderUsed) {
+            cm_editor.setSelection({ line: selectedLines[0], ch: 3 }, { line: selectedLines[0], ch: 3 + placeholder.length });
+        }
+    },
+    expand: function() {
+        // Determine the current format of the first selected line
+        // If it already is a header -> undo the expand block
+        // otherwise, add a markdown header indicator
+        let selectedLines = otterwiki_editor._getSelectedLines()
+        const firstLineNum = selectedLines[0];
+        const firstLine = cm_editor.getLine(firstLineNum);
+
+        const headerPlaceholder = "Summary";
+        const contentPlaceholder = "Folded content";
+        let extendSelection = false;
+        let placeholderUsed = false;
+
+        // Apply the header regex (matches both, lines with expand and lines without)
+        let updatedLine = firstLine.replace(/(\s*>\|\s+)?#\s+/, "$1")
+
+        // if the regex did not alter the line, there is no header present yet
+        // --> add the header prefix
+        if (updatedLine.length > 0 && updatedLine == firstLine) {
+            updatedLine = "# " + firstLine;
+        } else if (updatedLine.length == 0) {
+            placeholderUsed = true;
+            updatedLine = "# " + headerPlaceholder;
+
+            // if the user selected only one line, add placeholder content as well
+            if (selectedLines.length == 1) {
+                updatedLine += "\n" + contentPlaceholder;
+                extendSelection = true;
+            }
+        }
+        otterwiki_editor._setLine(firstLineNum, updatedLine);
+
+        // we have to extend the selection AFTER updating the line
+        // otherwise this would fail if extending below the current max line
+        if (extendSelection) {
+            otterwiki_editor._setSelectedLines([firstLineNum, firstLineNum + 1]);
+            selectedLines = otterwiki_editor._getSelectedLines();
+        }
+
+        // Finally, add the expand syntax on each selected line
+        otterwiki_editor._toggleLines(">| ", [/\s*>\|\s+/], "expand");
+
+        // Update selection in case a placeholder was used
+        if (placeholderUsed && !extendSelection) {
+            // ... select the header value, in case only the header was added as placeholder
+            // offset of 5 comes from the expand syntax '>| ' and the header syntax '# '
+            cm_editor.setSelection({ line: firstLineNum, ch: 5 }, { line: firstLineNum, ch: 5 + headerPlaceholder.length });
+
+        } else if (placeholderUsed && extendSelection) {
+            // ... select the content placeholder in case header AND content were placeholders
+            const lastLine = selectedLines[selectedLines.length - 1];
+
+            // offset of 3 comes from the expand syntax '>| '
+            cm_editor.setSelection({ line: lastLine, ch: 3 }, { line: lastLine, ch: 3 + contentPlaceholder.length });
+        }
+    },
     code: function() {
-        otterwiki_editor._toggleBlock(["`","```"], "code");
+        otterwiki_editor._toggleBlock(["`", "```"], "code");
+    },
+    codeBlock: function() {
+        otterwiki_editor._toggleMultilineBlock("```", /^```\w*/, null, "code");
+    },
+    // quote: increase the markdown quote level till five, remove afterwards
+    quote: function (multilevel = true) {
+        otterwiki_editor._toggleLinesMultiLevel(">", multilevel ? maxMultiLevels : 1);
     },
     ul: function() {
         otterwiki_editor._toggleLines("- ",[/\s*[-+*]\s+/], "ul");
@@ -207,6 +511,15 @@ var otterwiki_editor = {
     cl: function() {
         otterwiki_editor._toggleLines("- [ ] ",[/\s*[-+*] \[ \]\s+/], "ul");
     },
+    panelNotice: function() {
+        otterwiki_editor._toggleMultilineBlock(":::info", /^:::(info|warning|danger)/, ":::", ["# Header", "Content"]);
+    },
+    panelWarning: function() {
+        otterwiki_editor._toggleMultilineBlock(":::warning", /^:::(info|warning|danger)/, ":::", ["# Header", "Content"]);
+    },
+    panelDanger: function() {
+        otterwiki_editor._toggleMultilineBlock(":::danger", /^:::(info|warning|danger)/, ":::", ["# Header", "Content"]);
+    },
     img: function(img = "![]()") {
         if (!cm_editor) { return; }
         state = otterwiki_editor._getState();
@@ -220,6 +533,24 @@ var otterwiki_editor = {
 
         cm_editor.focus();
     },
+    diagram: function() {
+        otterwiki_editor._toggleMultilineBlock("```mermaid", /^```mermaid/, "```", "See https://mermaid.js.org/intro/");
+    },
+    alertNote: function() {
+        otterwiki_editor._toggleAlert("note", ["Content"]);
+    },
+    alertTip: function() {
+        otterwiki_editor._toggleAlert("tip", ["Content"]);
+    },
+    alertImportant: function() {
+        otterwiki_editor._toggleAlert("important", ["Content"]);
+    },
+    alertWarning: function() {
+        otterwiki_editor._toggleAlert("warning", ["Content"]);
+    },
+    alertCaution: function() {
+        otterwiki_editor._toggleAlert("caution", ["Content"]);
+    },
     link: function(text = "description", url = "https://") {
         if (!cm_editor) { return; }
         state = otterwiki_editor._getState();
@@ -233,6 +564,79 @@ var otterwiki_editor = {
 
         cm_editor.focus();
     },
+    footnote: function() {
+        const startChar = "[^";
+        const endChar = "]";
+
+        let selectedLines = otterwiki_editor._getSelectedLines();
+        if (selectedLines.length > 1) {
+            // If multiple lines are selected, use the last one
+            otterwiki_editor._setSelectedLines(selectedLines.slice(selectedLines.length - 1));
+            selectedLines = otterwiki_editor._getSelectedLines();
+        }
+
+        // Determine how to insert the footnote in the text
+        let footNoteValue = "footnote_identifier";
+        const selectedText = cm_editor.getSelection();
+        const lineContent = cm_editor.getLine(selectedLines[0]);
+
+        // default values for assumption that the line is empty and no text is selected
+        let newLine = startChar + footNoteValue + endChar;
+        let fnIdentifierStart = startChar.length;
+        let fnIdentifierEnd = fnIdentifierStart + footNoteValue.length;
+
+        if (selectedText) { // Text is selected, make it the footnote content
+            const selectedTextStart = lineContent.indexOf(selectedText);
+            const selectedTextEnd = selectedTextStart + selectedText.length;
+
+            footNoteValue = selectedText;
+
+            newLine = lineContent.slice(0, selectedTextStart) + startChar + footNoteValue + endChar + lineContent.slice(selectedTextEnd);
+
+            fnIdentifierStart = selectedTextStart + startChar.length;
+            fnIdentifierEnd = selectedTextEnd + startChar.length
+
+        } else if (lineContent.length) { // No text is selected, but line is not empty
+            const cursorPos = cm_editor.getCursor('start');
+            newLine = lineContent.slice(0, cursorPos.ch) + newLine + lineContent.slice(cursorPos.ch);
+
+            fnIdentifierStart = cursorPos.ch + startChar.length;
+            fnIdentifierEnd = fnIdentifierStart + footNoteValue.length;
+        }
+
+        otterwiki_editor._setLine(selectedLines[0], newLine);
+
+        // Add the footnote to the end of the document
+        // TODO: It would be more user-friendly to insert the footnote at the correct position
+        const lastLine = cm_editor.lineCount() - 1;
+        const lastLineContent = cm_editor.getLine(lastLine);
+
+        // if the last line already is a foot note reference, simply append our current footnote
+        // otherwise, add an _extra_ newline to get some spacing to the actual content
+        const separator = lastLineContent.startsWith(startChar) && lastLineContent.includes(endChar + ": ") ? "" : "\n";
+        otterwiki_editor._setLine(lastLine, lastLineContent + separator + "\n" + startChar + footNoteValue + endChar + ": footnote_description");
+
+        // NOTE: it seems as though footnote references are supported anywhere in the document
+        //       as long as the lines starts with a correctly formatted footnote reference.
+        //       This is not considered here, footnote references are always appended to the document's end
+
+        // Last, select the newly added footnote's identifier
+        cm_editor.setSelection({ line: selectedLines[0], ch: fnIdentifierStart }, { line: selectedLines[0], ch: fnIdentifierEnd });
+        cm_editor.focus()
+    },
+    _findNextOccurenceLine: function(lineContent) {
+        // Find the next line starting AFTER the current selection that matches lineContent
+        const selectedLines = otterwiki_editor._getSelectedLines();
+        const lineAfterSelection = selectedLines[selectedLines.length - 1];
+        const editorLastLine = cm_editor.doc.lineCount();
+
+        for (let l = lineAfterSelection; l < editorLastLine; l++) {
+            if (cm_editor.getLine(l) == lineContent) {
+                return l;
+            }
+        }
+        return -1;
+    },
     _findBlock: function(selectBlock = false) {
         var block_start = Number.MAX_SAFE_INTEGER;
         var block_end = Number.MIN_SAFE_INTEGER;
@@ -798,4 +1202,3 @@ document.querySelector('#content-wrapper').addEventListener('scroll', (event) =>
     }
   }
 });
-
diff --git a/otterwiki/templates/editor.html b/otterwiki/templates/editor.html
index 41edac6..7d3c0ca 100644
--- a/otterwiki/templates/editor.html
+++ b/otterwiki/templates/editor.html
@@ -29,8 +29,8 @@
 <button onclick="otterwiki_editor.bold()" class="btn btn-editor btn-xs ml-5 hidden-sm-and-down" title="Bold"><i class="fa fa-bold"></i></button>
 <button onclick="otterwiki_editor.italic()" class="btn btn-editor btn-xs hidden-sm-and-down" title="Italic"><i class="fa fa-italic"></i></button>
 <button onclick="otterwiki_editor.strikethrough()" class="btn btn-editor btn-xs hidden-sm-and-down" title="Strikethrough"><i class="fa fa-strikethrough"></i></button>
-<button onclick="otterwiki_editor.code()" class="btn btn-editor btn-xs hidden-sm-and-down" title="Code"><i class="fa fa-code"></i></button>
-<button onclick="otterwiki_editor.header()" class="btn btn-editor btn-xs ml-5 hidden-sm-and-down" title="Header"><i class="fa fa-heading"></i></button>
+<button onclick="otterwiki_editor.header()" class="btn btn-editor btn-xs hidden-sm-and-down" title="Header"><i class="fa fa-heading"></i></button>
+<button onclick="otterwiki_editor.quote()" class="btn btn-editor btn-xs hidden-sm-and-down" title="Block Quote"><i class="fa fa-quote-left"></i></button>
 {##}
 <div class="dropdown">
   <button class="btn btn-editor ml-5 hidden-sm-and-down" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-list" title="List Menu">
@@ -58,8 +58,150 @@
   </div>
 </div>
 {##}
-<button onclick="otterwiki_editor.link()" class="btn btn-editor btn-xs ml-5 hidden-sm-and-down" title="Link"><i class="fas fa-link"></i></button>
-<button onclick="otterwiki_editor.img()" class="btn btn-editor btn-xs hidden-sm-and-down" title="Picture"><i class="far fa-image"></i></button>
+<div class="dropdown">
+  <button class="btn btn-editor ml-5 hidden-sm-and-down" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-code" title="Code Menu">
+    <i class="fas fa-code"></i>
+  </button>
+  <div class="dropdown-menu dropdown-menu-right w-150" aria-labelledby="navbar-dropdown-toggle-code">
+    <a onclick="otterwiki_editor.code()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-code"></i>
+        </span>
+        Inline Code
+    </a>
+    <a onclick="otterwiki_editor.codeBlock()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-file-code"></i>
+        </span>
+        Code Block
+    </a>
+  </div>
+</div>
+{##}
+{# TODO: Consider merging spoiler and folds with the panel blocks below - decide for a new icon #}
+<div class="dropdown">
+  <button class="btn btn-editor ml-5 hidden-sm-and-down" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-spoiler" title="Expandables Menu">
+    <i class="fas fa-eye-slash"></i>
+  </button>
+  <div class="dropdown-menu dropdown-menu-right w-150" aria-labelledby="navbar-dropdown-toggle-spoiler">
+    <a onclick="otterwiki_editor.spoiler()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-eye-slash"></i>
+        </span>
+        Spoiler Block
+    </a>
+    <a onclick="otterwiki_editor.expand()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-caret-square-down"></i>
+        </span>
+        Folded Block
+    </a>
+  </div>
+</div>
+{##}
+<div class="dropdown">
+  <button class="btn btn-editor ml-5 hidden-sm-and-down" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-blocks" title="Panel Menu">
+    <i class="fas fa-info-circle"></i>
+  </button>
+  <div class="dropdown-menu dropdown-menu-right w-150" aria-labelledby="navbar-dropdown-toggle-blocks">
+    <a onclick="otterwiki_editor.panelNotice()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-info-circle"></i>
+        </span>
+        Notice Panel
+    </a>
+    <a onclick="otterwiki_editor.panelWarning()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-exclamation-circle"></i>
+        </span>
+        Warning Panel
+    </a>
+    <a onclick="otterwiki_editor.panelDanger()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-times-circle"></i>
+        </span>
+        Danger Panel
+    </a>
+  </div>
+</div>
+{##}
+<div class="dropdown">
+  <button class="btn btn-editor hidden-sm-and-down" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-alerts" title="Alert Menu">
+    <i class="fas fa-bell"></i>
+  </button>
+  <div class="dropdown-menu dropdown-menu-right w-150" aria-labelledby="navbar-dropdown-toggle-alerts">
+    <a onclick="otterwiki_editor.alertNote()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-info-circle"></i>
+        </span>
+        Note
+    </a>
+    <a onclick="otterwiki_editor.alertTip()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-lightbulb"></i>
+        </span>
+        Tip
+    </a>
+    <a onclick="otterwiki_editor.alertImportant()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-star"></i>
+        </span>
+        Important
+    </a>
+    <a onclick="otterwiki_editor.alertWarning()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-exclamation-circle"></i>
+        </span>
+        Warning
+    </a>
+    <a onclick="otterwiki_editor.alertCaution()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-times-circle"></i>
+        </span>
+        Caution
+    </a>
+  </div>
+</div>
+{##}
+<div class="dropdown">
+  <button class="btn btn-editor ml-5 hidden-sm-and-down" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-references" title="References Menu">
+    <i class="fas fa-link"></i>
+  </button>
+  <div class="dropdown-menu dropdown-menu-right w-150" aria-labelledby="navbar-dropdown-toggle-references">
+    <a onclick="otterwiki_editor.link()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-link"></i>
+        </span>
+        Link
+    </a>
+    <a onclick="otterwiki_editor.footnote()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-asterisk"></i>
+        </span>
+        Footnote
+    </a>
+  </div>
+</div>
+{##}
+<div class="dropdown">
+  <button class="btn btn-editor hidden-sm-and-down" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-graphics" title="Graphics Menu">
+    <i class="fas fa-images"></i>
+  </button>
+  <div class="dropdown-menu dropdown-menu-right w-150" aria-labelledby="navbar-dropdown-toggle-graphics">
+    <a onclick="otterwiki_editor.img()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-image"></i>
+        </span>
+        Picture
+    </a>
+    <a onclick="otterwiki_editor.diagram()" href="#" class="dropdown-item-with-icon">
+        <span class="dropdown-icon">
+            <i class="fas fa-project-diagram"></i>
+        </span>
+        Diagram
+    </a>
+  </div>
+</div>
 {##}
 <div class="dropdown">
   <button class="btn btn-editor ml-5 mr-5 hidden-sm-and-down" data-toggle="dropdown" type="button" id="navbar-dropdown-toggle-table" title="Table Menu">
diff --git a/otterwiki/templates/layout.html b/otterwiki/templates/layout.html
index f31df8c..83f2b91 100644
--- a/otterwiki/templates/layout.html
+++ b/otterwiki/templates/layout.html
@@ -67,7 +67,7 @@
 {%- block bodytop %}
 {% endblock -%}
     <!-- Page wrapper start -->
-    <div id="page-wrapper" class="page-wrapper {% block page_wrapper_config -%}with-navbar with-sidebar{%-endblock%}" {%if request.cookies.get('halfmoon_sidebar') == "hidden" %}data-sidebar-hidden="hidden" {%elif request.cookies.get('halfmoon_sidebar') == "visible" %}data-show-sidebar-onload-sm-and-down="visible" {%endif%}data-sidebar-type="overlayed-sm-and-down">
+    <div id="page-wrapper" class="page-wrapper {% block page_wrapper_config -%}with-navbar with-sidebar{%-endblock%}" {%if request.cookies.get('halfmoon_sidebar') == "hidden" %}data-sidebar-hidden="hidden"{%-elif request.cookies.get('halfmoon_sidebar') == "visible" %} data-show-sidebar-onload-sm-and-down="visible"{%-endif-%} data-sidebar-type="overlayed-sm-and-down">
 
         <!-- Sticky alerts -->
         <div class="sticky-alerts"></div>
diff --git a/otterwiki/templates/snippets/syntax.html b/otterwiki/templates/snippets/syntax.html
index d419b45..0047251 100644
--- a/otterwiki/templates/snippets/syntax.html
+++ b/otterwiki/templates/snippets/syntax.html
@@ -237,8 +237,8 @@ print("Hello Line Numbers!")
 ```</pre>
 </div>
 <div class="col-md-6 col-sm-12">
-<div class="highlight"><table class="highlighttable"><tbody><tr><td class="linenos"><div class="linenodiv"><pre><span class="normal">1</span></pre></div></td><td class="code"><pre class="code"><span class=".highlight nb">print</span><span class=".highlight p">(</span><span class=".highlight s2">"Hello Line Numbers!"</span><span class=".highlight p">)</span>
-</pre></td></tr></tbody></table></div>
+<div class="highlight"><table class="highlighttable"><tbody><tr><td class="linenos"><div class="linenodiv"><pre><span class="normal">1</span></pre></div></td><td class="code"><div><div class="copy-to-clipboard-outer"><div class="copy-to-clipboard-inner"><button class="btn alt-dm btn-xsm copy-to-clipboard" onclick="otterwiki.copy_to_clipboard(this);" type="button"><i "="" alt="Copy to clipboard" aria-hidden="true" class="fa fa-copy"></i></button></div><pre class="copy-to-clipboard code"><span class=".highlight nb">print</span><span class=".highlight p">(</span><span class=".highlight s2">"Hello Line Numbers!"</span><span class=".highlight p">)</span>
+</pre></div></div></td></tr></tbody></table></div>
 </div>
 </div>
 
diff --git a/otterwiki/version.py b/otterwiki/version.py
index 4f248e1..236908d 100644
--- a/otterwiki/version.py
+++ b/otterwiki/version.py
@@ -2,7 +2,7 @@
 # vim: set et ts=8 sts=4 sw=4 ai:
 
 # version_onfo managed by tbump
-version_info = (2, 9, 4, "")
+version_info = (2, 9, 3, "")
 
 # build version string from version_info
 __version__ = f"{version_info[0]}.{version_info[1]}.{version_info[2]}" + (
diff --git a/otterwiki/wiki.py b/otterwiki/wiki.py
index 3e1d00a..854b18e 100644
--- a/otterwiki/wiki.py
+++ b/otterwiki/wiki.py
@@ -389,8 +389,7 @@ class Changelog:
 
         url_map = patchset2urlmap(patchset, revision)
         if len(url_map) == 1:
-            filename = list(url_map.keys())[0]
-            pagepath = get_pagepath(get_pagename(filename, full=True))
+            pagepath = get_pagepath(get_pagename(list(url_map.keys())[0]))
 
         menutree = SidebarPageIndex(get_page_directoryname(pagepath or "/"))
 
diff --git a/pyproject.toml b/pyproject.toml
index 21a636f..37df457 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,7 @@ dependencies = [
     "SQLAlchemy==2.0.36",
     "Flask-SQLAlchemy==3.1.1",
     "Flask==3.1.0",
-    "Jinja2==3.1.6",
+    "Jinja2==3.1.5",
     "gitpython==3.1.42",
     "cython==3.0.11",
     "mistune==2.0.5",
diff --git a/tbump.toml b/tbump.toml
index b84f78c..296d3c2 100644
--- a/tbump.toml
+++ b/tbump.toml
@@ -1,5 +1,5 @@
 [version]
-current = "2.9.4"
+current = "2.9.3"
 regex = '''
   (?P<major>\d+)
   \.


Test patch:
diff --git a/tests/test_renderer.py b/tests/test_renderer.py
index e7327d2..9f8858c 100644
--- a/tests/test_renderer.py
+++ b/tests/test_renderer.py
@@ -361,7 +361,7 @@ $latex3$"""
     assert "\\(latex6\\)" in html
 
 
-def test_footnote_named():
+def test_footnote():
     md = """Footnote identifier[^1] are single characters or words[^bignote].
 And can be referenced multiple[^1] times.
 
@@ -379,10 +379,6 @@ And can be referenced multiple[^1] times.
     assert 'href="#fn-1"' in html
     assert 'id="fnref-1"' in html
     assert 'href="#fnref-1"' in html
-    assert 'id="fn-2"' in html
-    assert 'href="#fn-2"' in html
-    assert 'id="fnref-2"' in html
-    assert 'href="#fnref-2"' in html
 
 
 def test_footnote_multiref():
@@ -577,7 +573,7 @@ def test_indent_preformatted_issue206():
     assert pre
     assert (
         """ Hello :) This is at 5 spaces.
- 5 spaces here as well."""
+ 5 spaces here as well.\n"""
         == pre.text
     )
     # backtick block
@@ -615,15 +611,3 @@ def test_indent_preformatted_issue206():
   }\n"""
         == code.text
     )
-
-
-def test_indent_preformatted_issue212():
-    md = """# Example page
-
-    This is preformatted.
-
-This is regular."""
-    html, _ = render.markdown(md)
-    pre = BeautifulSoup(html, "html.parser").find('pre')
-    assert pre
-    assert """This is preformatted.\n""" == pre.text