xref: /dokuwiki/lib/scripts/page.js (revision b7c3b74a96d300996e68044da8247ce07a7472ed)
1/**
2 * Page behaviours
3 *
4 * This class adds various behaviours to the rendered page
5 */
6dw_page = {
7    /**
8     * initialize page behaviours
9     */
10    init: function(){
11        dw_page.sectionHighlight();
12        dw_page.currentIDHighlight();
13        jQuery('a.fn_top').on('mouseover', dw_page.footnoteDisplay);
14        dw_page.makeToggle('#dw__toc h3','#dw__toc > div');
15        dw_page.copyCode();
16    },
17
18    /**
19     * Highlight the section when hovering over the appropriate section edit button
20     *
21     * @author Andreas Gohr <andi@splitbrain.org>
22     */
23    sectionHighlight: function() {
24        jQuery('form.btn_secedit')
25            /*
26             * wrap the editable section in a div
27             */
28            .each(function () {
29                let $tgt = jQuery(this).parent();
30                const match = $tgt.attr('class').match(/(\s+|^)editbutton_(\d+)(\s+|$)/);
31                if(!match) return;
32                const nr = match[2];
33                let $highlight = jQuery(); // holder for elements in the section to be highlighted
34                const $highlightWrap = jQuery('<div class="section_highlight_wrapper"></div>');
35
36                // the edit button should be part of the highlight
37                $highlight = $highlight.add($tgt);
38
39                // Walk the dom tree in reverse to find the sibling which is or contains the section edit marker
40                while ($tgt.length > 0 && !($tgt.hasClass('sectionedit' + nr) || $tgt.find('.sectionedit' + nr).length)) {
41                    $tgt = $tgt.prev();
42                    $highlight = $highlight.add($tgt);
43                }
44                // wrap the elements to be highlighted in the section highlight wrapper
45                $highlight.wrapAll($highlightWrap);
46            })
47            /*
48             * highlight the section
49             */
50            .on('mouseover', function () {
51                jQuery(this).parents('.section_highlight_wrapper').addClass('section_highlight');
52            })
53            /*
54             * remove highlight
55             */
56            .on('mouseout', function () {
57                jQuery(this).parents('.section_highlight_wrapper').removeClass('section_highlight');
58            });
59    },
60
61
62    /**
63     * Highlight internal link pointing to current page
64     *
65     * @author Henry Pan <dokuwiki@phy25.com>
66     */
67    currentIDHighlight: function(){
68        jQuery('a.wikilink1, a.wikilink2').filter('[data-wiki-id="'+JSINFO.id+'"]').wrap('<span class="curid"></span>');
69    },
70
71    /**
72     * Create/get a insitu popup used by the footnotes
73     *
74     * @param target - the DOM element at which the popup should be aligned at
75     * @param popup_id - the ID of the (new) DOM popup
76     * @return the Popup jQuery object
77     */
78    insituPopup: function(target, popup_id) {
79        // get or create the popup div
80        var $fndiv = jQuery('#' + popup_id);
81
82        // popup doesn't exist, yet -> create it
83        if($fndiv.length === 0){
84            $fndiv = jQuery(document.createElement('div'))
85                .attr('id', popup_id)
86                .addClass('insitu-footnote JSpopup')
87                .attr('aria-hidden', 'true')
88                .on('mouseleave', function () {jQuery(this).hide().attr('aria-hidden', 'true');})
89                .attr('role', 'tooltip');
90            jQuery('.dokuwiki:first').append($fndiv);
91        }
92
93        // position() does not support hidden elements
94        $fndiv.show().position({
95            my: 'left top',
96            at: 'left center',
97            of: target
98        }).hide();
99
100        return $fndiv;
101    },
102
103    /**
104     * Display an insitu footnote popup
105     *
106     * @author Andreas Gohr <andi@splitbrain.org>
107     * @author Chris Smith <chris@jalakai.co.uk>
108     * @author Anika Henke <anika@selfthinker.org>
109     */
110    footnoteDisplay: function () {
111        var $content = jQuery(jQuery(this).attr('href')) // Footnote text anchor
112                      .parent().siblings('.content').clone();
113
114        if (!$content.length) {
115            return;
116        }
117
118        // prefix ids on any elements with "insitu__" to ensure they remain unique
119        jQuery('[id]', $content).each(function(){
120            var id = jQuery(this).attr('id');
121            jQuery(this).attr('id', 'insitu__' + id);
122        });
123
124        var content = $content.html().trim();
125        // now put the content into the wrapper
126        dw_page.insituPopup(this, 'insitu__fn').html(content)
127        .show().attr('aria-hidden', 'false');
128    },
129
130    /**
131     * Makes an element foldable by clicking its handle
132     *
133     * This is used for the TOC toggling, but can be used for other elements
134     * as well. A state indicator is inserted into the handle and can be styled
135     * by CSS.
136     *
137     * To properly reserve space for the expanded element, the sliding animation is
138     * done on the children of the content. To make that look good and to make sure aria
139     * attributes are assigned correctly, it's recommended to make sure that the content
140     * element contains a single child element only.
141     *
142     * @param {selector} handle What should be clicked to toggle
143     * @param {selector} content This element will be toggled
144     * @param {int} state initial state (-1 = open, 1 = closed)
145     */
146    makeToggle: function(handle, content, state){
147        var $handle, $content, $clicky, $child, setClicky;
148        $handle = jQuery(handle);
149        if(!$handle.length) return;
150        $content = jQuery(content);
151        if(!$content.length) return;
152
153        // we animate the children
154        $child = $content.children();
155
156        // class/display toggling
157        setClicky = function(hiding){
158            if(hiding){
159                $clicky.html('<span>+</span>');
160                $handle.addClass('closed');
161                $handle.removeClass('open');
162            }else{
163                $clicky.html('<span>−</span>');
164                $handle.addClass('open');
165                $handle.removeClass('closed');
166            }
167        };
168
169        $handle[0].setState = function(state){
170            var hidden;
171            if(!state) state = 1;
172
173            // Assert that content instantly takes the whole space
174            $content.css('min-height', $content.height()).show();
175
176            // stop any running animation
177            $child.stop(true, true);
178
179            // was a state given or do we toggle?
180            if(state === -1) {
181                hidden = false;
182            } else if(state === 1) {
183                hidden = true;
184            } else {
185                hidden = $child.is(':hidden');
186            }
187
188            // update the state
189            setClicky(!hidden);
190
191            // Start animation and assure that $toc is hidden/visible
192            $child.dw_toggle(hidden, function () {
193                $content.toggle(hidden);
194                $content.attr('aria-expanded', hidden);
195                $content.css('min-height',''); // remove min-height again
196            }, true);
197        };
198
199        // the state indicator
200        $clicky = jQuery(document.createElement('strong'));
201
202        // click function
203        $handle.css('cursor','pointer')
204               .on('click', $handle[0].setState)
205               .prepend($clicky);
206
207        // initial state
208        $handle[0].setState(state);
209    },
210
211    /**
212     * Provides simple copy-to-clipboard functionality for code blocks.
213     *
214     * This is supposed to work only in secure context, which is required
215     * by the Clipboard API. Intentionally there is no fallback using
216     * the deprecated document.execCommand()
217     */
218    copyCode: function () {
219        /**
220         * Wrap code block in a container and attach a copy button
221         * @param {Element} code
222         */
223        function addWrapper(code) {
224
225            const codeWrapper = document.createElement("div");
226            codeWrapper.classList.add("code-wrapper");
227
228            code.parentNode.insertBefore(codeWrapper, code);
229            codeWrapper.appendChild(code);
230
231            const copyButton = document.createElement("button");
232            copyButton.classList.add("code-copy-btn");
233            copyButton.innerText = LANG["clipboard_button"];
234
235            copyButton.addEventListener("click", copyToClipboard);
236            codeWrapper.appendChild(copyButton);
237        }
238
239        /**
240         * Copy the code into system clipboard
241         *
242         * @returns {Promise<void>}
243         */
244        async function copyToClipboard(){
245            const codeWrapper = this.closest('.code-wrapper');
246            const preElement = codeWrapper.querySelector("pre");
247
248            let textToCopy = "";
249
250            // code with line numbers requires special unpacking to keep line breaks, we read it line by line
251            let codeLines = preElement.querySelectorAll("li > div");
252            if (codeLines.length === 0) {
253                textToCopy = preElement.innerText; // normal code block
254            } else {
255                textToCopy += Array.from(codeLines)
256                    .map(line => line.innerText)
257                    .join("\n");
258            }
259
260            // copy text and show operation status in the button
261            try {
262                await navigator.clipboard.writeText(textToCopy);
263                this.textContent = LANG["clipboard_success"];
264            } catch (err) {
265                this.textContent = LANG["clipboard_error"];
266            } finally {
267                setTimeout(() => {
268                    this.textContent = LANG["clipboard_button"]; // reset button text after a delay
269                }, 2000);
270            }
271        }
272
273        // do nothing unless we have code blocks and access to the Clipboard object (only in secure context)
274        const codeBlocks = document.querySelectorAll("pre.code, pre.file");
275        if (codeBlocks.length > 0 && typeof navigator.clipboard === "object") {
276            codeBlocks.forEach(addWrapper);
277        }
278    }
279};
280
281jQuery(dw_page.init);
282