1<?php
2/**
3 * DokuWiki Plugin mermaid (Action Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Robert Weinmeister <develop@weinmeister.org>
7 */
8
9if (!defined('DOKU_INC')) die();
10
11class action_plugin_mermaid extends \dokuwiki\Extension\ActionPlugin
12{
13    /** @inheritDoc */
14    public function register(Doku_Event_Handler $controller)
15    {
16        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'load');
17        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjaxRequest');
18    }
19
20    public function handleAjaxRequest(Doku_Event $event, $param) {
21        if ($event->data !== 'plugin_mermaid')
22        {
23            return;
24        }
25
26        $event->stopPropagation();
27        $event->preventDefault();
28
29        $ID = cleanID(urldecode($_REQUEST['pageid']));
30
31        if(auth_quickaclcheck($ID) < AUTH_EDIT)
32        {
33            echo json_encode(['status' => 'failure', 'data' => ['You do not have permission to edit this file.\nAccess was denied.']]);
34            exit();
35        }
36
37        if(checklock($ID))
38        {
39            echo json_encode(['status' => 'failure', 'data' => ['The page is currently locked.\nTry again later.']]);
40            exit();
41        }
42
43        $wikitext = rawWiki($ID);
44        $newWikitext = $wikitext;
45
46        if($_REQUEST['mode'] == 'lock')
47        {
48            preg_match_all('/<mermaid.*?>(.*?)<\/mermaid>/s', $wikitext, $matches, PREG_OFFSET_CAPTURE);
49
50            if(is_array($matches) && count($matches[0]) > $_REQUEST['mermaidindex'])
51            {
52                $whereToInsert = $matches[1][$_REQUEST['mermaidindex']][1];
53                $newWikitext = substr($wikitext, 0, $whereToInsert) . "\n%%" . urldecode($_REQUEST['svg']) . "\n" . substr($wikitext, $whereToInsert);
54            }
55            else
56            {
57                echo json_encode(['status' => 'failure', 'data' => ['Could not lock the Mermaid diagram as the request could not be matched.']]);
58                exit();
59            }
60        }
61
62        if($_REQUEST['mode'] == 'unlock')
63        {
64            $newWikitext = str_replace("\n%%" . urldecode($_REQUEST['svg']) . "\n", '', $wikitext, $count);
65            if($count != 1)
66            {
67                echo json_encode(['status' => 'failure', 'data' => ['Could not unlock the Mermaid diagram as the request could not be matched.']]);
68                exit();
69            }
70        }
71
72        if(strlen($newWikitext) > 0 && $newWikitext != $wikitext)
73        {
74            lock($ID);
75            saveWikiText($ID, $newWikitext, $_REQUEST['mode'] . ' Mermaid diagram', $minoredit = true);
76            unlock($ID);
77
78            echo json_encode(['status' => 'success', 'data' => []]);
79            exit();
80        }
81
82        echo json_encode(['status' => 'failure', 'data' => ['Could not '.$_REQUEST['mode'].' the Mermaid diagram.']]);
83        exit();
84    }
85
86    public function load(Doku_Event $event, $param)
87    {
88        // only load mermaid if it is needed
89        if(strpos(rawWiki(getID()), '<mermaid') === false)
90        {
91            return;
92        }
93
94        // Can be changed for debugging Mermaid
95        // https://mermaid.js.org/config/directives.html#changing-loglevel-via-directive
96        define("MERMAIDLOGLEVEL", "error");
97
98        $theme = $this->getConf('theme');
99        $init = "mermaid.initialize({startOnLoad: true, logLevel: '".MERMAIDLOGLEVEL."', theme: '".$theme."'});";
100        $location = $this->getConf('location');
101
102        switch ($location) {
103            case 'local':
104                $event->data['script'][] = array
105                (
106                    'type'    => 'text/javascript',
107                    'charset' => 'utf-8',
108                    'src' => DOKU_BASE.'lib/plugins/mermaid/mermaid.min.js'
109                );
110                break;
111            case 'latest':
112            case 'remote1091':
113            // options remote108, remote106, remote104, remote103, remote102, remote101, remote100 are depreciated and only included for backward compatibility
114            case 'remote108':
115            case 'remote106':
116            case 'remote104':
117            case 'remote103':
118            case 'remote102':
119            case 'remote101':
120            case 'remote100':
121                $versions = array(
122                    'latest' => '',
123                    'remote1091' => '@10.9.1',
124                    'remote108' => '@10.8.0',
125                    'remote106' => '@10.6.1',
126                    'remote104' => '@10.4.0',
127                    'remote103' => '@10.3.1',
128                    'remote102' => '@10.2.4',
129                    'remote101' => '@10.1.0',
130                    'remote100' => '@10.0.2'
131                );
132                $data = "import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid".$versions[$location]."/dist/mermaid.esm.min.mjs';".$init;
133                $event->data['script'][] = array
134                (
135                    'type'    => 'module',
136                    'charset' => 'utf-8',
137                    '_data' => $data
138                );
139                break;
140            // option remote94 is depreciated and only included for backward compatibility
141            case 'remote94':
142            case 'remote943':
143                $event->data['script'][] = array
144                (
145                    'type'    => 'text/javascript',
146                    'charset' => 'utf-8',
147                    'src' => 'https://cdn.jsdelivr.net/npm/mermaid@9.4.3/dist/mermaid.min.js'
148                );
149                break;
150            // option remote93 is depreciated and only included for backward compatibility
151            case 'remote93':
152                $event->data['script'][] = array
153                (
154                    'type'    => 'text/javascript',
155                    'charset' => 'utf-8',
156                    'src' => 'https://cdn.jsdelivr.net/npm/mermaid@9.3.0/dist/mermaid.min.js'
157                );
158                break;
159            default:
160        }
161
162        $event->data['link'][] = array
163        (
164            'rel'     => 'stylesheet',
165            'type'    => 'text/css',
166            'href'    => DOKU_BASE."lib/plugins/mermaid/mermaid.css",
167        );
168
169        switch ($location) {
170            case 'local':
171            case 'remote943':
172            // options remote94 and remote93 are depreciated and only included for backward compatibility
173            case 'remote94':
174            case 'remote93':
175                $event->data['script'][] = array
176                (
177                    'type'    => 'text/javascript',
178                    'charset' => 'utf-8',
179                    '_data'   => $init
180                );
181                break;
182            default:
183        }
184
185        // remove the search highlight from DokuWiki as it interferes with the Mermaid parsing/rendering
186        $event->data['script'][] = array
187        (
188            'type'    => 'text/javascript',
189            'charset' => 'utf-8',
190            '_data' => "document.addEventListener('DOMContentLoaded', function() {
191                            jQuery('.mermaid').each(function() {
192                                var modifiedContent = jQuery(this).html().replace(/<span class=\"search_hit\">(.+?)<\/span>/g, '$1');
193                                jQuery(this).html(modifiedContent);
194                             })
195                        });"
196        );
197
198        // adds image-save capability
199        // First: Wait until the DOM content is fully loaded
200        // Second: Wait until Mermaid has changed the dokuwiki content to an svg
201        $event->data['script'][] = array
202        (
203            'type'    => 'text/javascript',
204            'charset' => 'utf-8',
205            '_data' => "
206document.addEventListener('DOMContentLoaded', function() {
207     var config = {
208        childList: true,
209        subtree: true,
210        characterData: true
211    };
212
213    function callDokuWikiPHP(mode, index, mermaidRaw, mermaidSvg) {
214    jQuery.post(
215        DOKU_BASE + 'lib/exe/ajax.php',
216        {
217            call: 'plugin_mermaid',
218            mode: mode,
219            mermaidindex: index,
220            pageid: '".getID()."',
221            svg: encodeURIComponent(mermaidSvg)
222        },
223        function(response) {
224            if(response.status == 'success') {
225                location.reload(true);
226            }
227            else {
228                alert(response.data[0]);
229            }
230        },
231        'json'
232    )};
233
234    jQuery('.mermaidlocked, .mermaid').each(function(index, element) {
235        document.getElementById('mermaidContainer' + index).addEventListener('mouseenter', function() {
236             document.getElementById('mermaidFieldset' + index).style.display = 'flex';
237        });
238        document.getElementById('mermaidContainer' + index).addEventListener('mouseleave', function() {
239            document.getElementById('mermaidFieldset' + index).style.display = 'none';
240        });
241
242        if(jQuery(element).hasClass('mermaidlocked')) {
243            document.getElementById('mermaidButtonSave' + index).addEventListener('click', () => {
244                var svgContent = element.innerHTML.trim();
245                var blob = new Blob([svgContent], { type: 'image/svg+xml' });
246                var link = document.createElement('a');
247                link.href = URL.createObjectURL(blob);
248                link.download = 'mermaid' + index + '.svg';
249                link.click();
250                URL.revokeObjectURL(link.href);
251            });
252
253            document.getElementById('mermaidButtonPermanent' + index).addEventListener('click', () => {
254                if(confirm('Unlock Mermaid diagram?')) {
255                    callDokuWikiPHP('unlock', index, originalMermaidContent, element.innerHTML.trim());
256                }
257            });
258        }
259
260        if(jQuery(element).hasClass('mermaid')) {
261            var originalMermaidContent = element.innerHTML;
262            var observer = new MutationObserver(function(mutations) {
263                mutations.forEach(function(mutation) {
264                    if (mutation.type === 'childList' && element.innerHTML.startsWith('<svg')) {
265                        document.getElementById('mermaidButtonSave' + index).addEventListener('click', () => {
266                            var svgContent = element.innerHTML.trim();
267                            var blob = new Blob([svgContent], { type: 'image/svg+xml' });
268                            var link = document.createElement('a');
269                            link.href = URL.createObjectURL(blob);
270                            link.download = 'mermaid' + index + '.svg';
271                            link.click();
272                            URL.revokeObjectURL(link.href);
273                        });
274
275                       document.getElementById('mermaidButtonPermanent' + index).addEventListener('click', () => {
276                            if(confirm('Lock Mermaid diagram? [experimental]')) {
277                                callDokuWikiPHP('lock', index, originalMermaidContent, element.innerHTML.trim());
278                            }
279                       });
280                    }
281                });
282            });
283            observer.observe(element, config);
284        }
285    });
286});"
287        );
288    }
289}
290
291