1<?php
2
3// phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
4
5/**
6 * DokuWiki Plugin prosemirror (Renderer Component)
7 *
8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
9 * @author  Andreas Gohr <gohr@cosmocode.de>
10 */
11
12use dokuwiki\plugin\prosemirror\parser\ImageNode;
13use dokuwiki\plugin\prosemirror\parser\LocalLinkNode;
14use dokuwiki\plugin\prosemirror\parser\InternalLinkNode;
15use dokuwiki\plugin\prosemirror\parser\ExternalLinkNode;
16use dokuwiki\plugin\prosemirror\parser\InterwikiLinkNode;
17use dokuwiki\plugin\prosemirror\parser\EmailLinkNode;
18use dokuwiki\plugin\prosemirror\parser\WindowsShareLinkNode;
19use dokuwiki\Extension\Event;
20use dokuwiki\plugin\prosemirror\schema\Mark;
21use dokuwiki\plugin\prosemirror\schema\Node;
22use dokuwiki\plugin\prosemirror\schema\NodeStack;
23
24class renderer_plugin_prosemirror extends Doku_Renderer
25{
26    /** @var  NodeStack */
27    public $nodestack;
28
29    /** @var NodeStack[] */
30    protected $nodestackBackup = [];
31
32    /** @var array list of currently active formatting marks */
33    protected $marks = [];
34
35    /** @var int column counter for table handling */
36    protected $colcount = 0;
37
38    /**
39     * The format this renderer produces
40     */
41    public function getFormat()
42    {
43        return 'prosemirror';
44    }
45
46    public function addToNodestackTop(Node $node)
47    {
48        $this->nodestack->addTop($node);
49    }
50
51    public function addToNodestack(Node $node)
52    {
53        $this->nodestack->add($node);
54    }
55
56    public function dropFromNodeStack($nodeType)
57    {
58        $this->nodestack->drop($nodeType);
59    }
60
61    public function getCurrentMarks()
62    {
63        return $this->marks;
64    }
65
66    /**
67     * If there is a block scope open, close it.
68     */
69    protected function clearBlock()
70    {
71        $parentNode = $this->nodestack->current()->getType();
72        if ($parentNode == 'paragraph') {
73            $this->nodestack->drop($parentNode);
74        }
75    }
76
77    // FIXME implement all methods of Doku_Renderer here
78
79    /** @inheritDoc */
80    public function document_start()
81    {
82        $this->nodestack = new NodeStack();
83    }
84
85    /** @inheritDoc */
86    public function document_end()
87    {
88        if ($this->nodestack->isEmpty()) {
89            $this->p_open();
90            $this->p_close();
91        }
92        $this->doc = json_encode($this->nodestack->doc(), JSON_PRETTY_PRINT);
93    }
94
95    public function nocache()
96    {
97        $docNode = $this->nodestack->getDocNode();
98        $docNode->attr('nocache', true);
99    }
100
101    public function notoc()
102    {
103        $docNode = $this->nodestack->getDocNode();
104        $docNode->attr('notoc', true);
105    }
106
107    /** @inheritDoc */
108    public function p_open()
109    {
110        $this->nodestack->addTop(new Node('paragraph'));
111    }
112
113    /** @inheritdoc */
114    public function p_close()
115    {
116        $this->nodestack->drop('paragraph');
117    }
118
119    /** @inheritDoc */
120    public function quote_open()
121    {
122        if ($this->nodestack->current()->getType() === 'paragraph') {
123            $this->nodestack->drop('paragraph');
124        }
125        $this->nodestack->addTop(new Node('blockquote'));
126    }
127
128    /** @inheritDoc */
129    public function quote_close()
130    {
131        if ($this->nodestack->current()->getType() === 'paragraph') {
132            $this->nodestack->drop('paragraph');
133        }
134        $this->nodestack->drop('blockquote');
135    }
136
137    #region lists
138
139    /** @inheritDoc */
140    public function listu_open()
141    {
142        if ($this->nodestack->current()->getType() === 'paragraph') {
143            $this->nodestack->drop('paragraph');
144        }
145
146        $this->nodestack->addTop(new Node('bullet_list'));
147    }
148
149    /** @inheritDoc */
150    public function listu_close()
151    {
152        $this->nodestack->drop('bullet_list');
153    }
154
155    /** @inheritDoc */
156    public function listo_open()
157    {
158        if ($this->nodestack->current()->getType() === 'paragraph') {
159            $this->nodestack->drop('paragraph');
160        }
161
162        $this->nodestack->addTop(new Node('ordered_list'));
163    }
164
165    /** @inheritDoc */
166    public function listo_close()
167    {
168        $this->nodestack->drop('ordered_list');
169    }
170
171    /** @inheritDoc */
172    public function listitem_open($level, $node = false)
173    {
174        $this->nodestack->addTop(new Node('list_item'));
175
176        $paragraphNode = new Node('paragraph');
177        $this->nodestack->addTop($paragraphNode);
178    }
179
180    /** @inheritDoc */
181    public function listitem_close()
182    {
183
184        if ($this->nodestack->current()->getType() === 'paragraph') {
185            $this->nodestack->drop('paragraph');
186        }
187        $this->nodestack->drop('list_item');
188    }
189
190    #endregion lists
191
192    #region table
193
194    /** @inheritDoc */
195    public function table_open($maxcols = null, $numrows = null, $pos = null)
196    {
197        $this->nodestack->addTop(new Node('table'));
198    }
199
200    /** @inheritDoc */
201    public function table_close($pos = null)
202    {
203        $this->nodestack->drop('table');
204    }
205
206    /** @inheritDoc */
207    public function tablerow_open()
208    {
209        $this->nodestack->addTop(new Node('table_row'));
210        $this->colcount = 0;
211    }
212
213    /** @inheritDoc */
214    public function tablerow_close()
215    {
216        $node = $this->nodestack->drop('table_row');
217        $node->attr('columns', $this->colcount);
218    }
219
220    /** @inheritDoc */
221    public function tablecell_open($colspan = 1, $align = null, $rowspan = 1)
222    {
223        $this->openTableCell('table_cell', $colspan, $align, $rowspan);
224    }
225
226    /** @inheritdoc */
227    public function tablecell_close()
228    {
229        $this->closeTableCell('table_cell');
230    }
231
232    /** @inheritDoc */
233    public function tableheader_open($colspan = 1, $align = null, $rowspan = 1)
234    {
235        $this->openTableCell('table_header', $colspan, $align, $rowspan);
236    }
237
238    /** @inheritdoc */
239    public function tableheader_close()
240    {
241        $this->closeTableCell('table_header');
242    }
243
244    /**
245     * Add a new table cell to the top of the stack
246     *
247     * @param string      $type    either table_cell or table_header
248     * @param int         $colspan
249     * @param string|null $align   either null/left, center or right
250     * @param int         $rowspan
251     */
252    protected function openTableCell($type, $colspan, $align, $rowspan)
253    {
254        $this->colcount += $colspan;
255
256        $node = new Node($type);
257        $node->attr('colspan', $colspan);
258        $node->attr('rowspan', $rowspan);
259        $node->attr('align', $align);
260
261        $this->nodestack->addTop($node);
262
263        $node = new Node('paragraph');
264        $this->nodestack->addTop($node);
265    }
266
267    /**
268     * Remove a table cell from the top of the stack
269     *
270     * @param string $type either table_cell or table_header
271     */
272    protected function closeTableCell($type)
273    {
274        if ($this->nodestack->current()->getType() === 'paragraph') {
275            $this->nodestack->drop('paragraph');
276        }
277
278        $curNode = $this->nodestack->current();
279        $curNode->trimContentLeft();
280        $curNode->trimContentRight();
281
282        $this->nodestack->drop($type);
283    }
284
285    #endregion table
286
287    /** @inheritDoc */
288    public function header($text, $level, $pos)
289    {
290        $node = new Node('heading');
291        $node->attr('level', $level);
292
293        $tnode = new Node('text');
294        $tnode->setText($text);
295
296        $node->addChild($tnode);
297
298        $this->nodestack->add($node);
299    }
300
301    /** @inheritDoc */
302    public function cdata($text)
303    {
304        if ($text === '') {
305            return;
306        }
307
308        $parentNode = $this->nodestack->current()->getType();
309
310        if (in_array($parentNode, ['paragraph', 'footnote'])) {
311            $text = str_replace("\n", ' ', $text);
312        }
313
314        if ($parentNode === 'list_item') {
315            $node = new Node('paragraph');
316            $this->nodestack->addTop($node);
317        }
318
319        if ($parentNode === 'blockquote') {
320            $node = new Node('paragraph');
321            $this->nodestack->addTop($node);
322        }
323
324        if ($parentNode === 'doc') {
325            $node = new Node('paragraph');
326            $this->nodestack->addTop($node);
327        }
328
329        $node = new Node('text');
330        $node->setText($text);
331        foreach (array_keys($this->marks) as $mark) {
332            $node->addMark(new Mark($mark));
333        }
334        $this->nodestack->add($node);
335    }
336
337    public function preformatted($text)
338    {
339        $this->clearBlock();
340        $node = new Node('preformatted');
341        $this->nodestack->addTop($node);
342        $this->cdata($text);
343        $this->nodestack->drop('preformatted');
344    }
345
346    public function code($text, $lang = null, $file = null)
347    {
348        $this->clearBlock();
349        $node = new Node('code_block');
350        $node->attr('class', 'code ' . $lang);
351        $node->attr('data-language', $lang);
352        $node->attr('data-filename', $file);
353
354        $this->nodestack->addTop($node);
355        $this->cdata(trim($text, "\n"));
356        $this->nodestack->drop('code_block');
357    }
358
359    public function file($text, $lang = null, $file = null)
360    {
361        $this->code($text, $lang, $file);
362    }
363
364    public function html($text)
365    {
366        $node = new Node('html_inline');
367        $node->attr('class', 'html_inline');
368
369        $this->nodestack->addTop($node);
370        $this->cdata(str_replace("\n", ' ', $text));
371        $this->nodestack->drop('html_inline');
372    }
373
374    public function htmlblock($text)
375    {
376        $this->clearBlock();
377        $node = new Node('html_block');
378        $node->attr('class', 'html_block');
379
380        $this->nodestack->addTop($node);
381        $this->cdata(trim($text, "\n"));
382        $this->nodestack->drop('html_block');
383    }
384
385    public function php($text)
386    {
387        $node = new Node('php_inline');
388        $node->attr('class', 'php_inline');
389
390        $this->nodestack->addTop($node);
391        $this->cdata(str_replace("\n", ' ', $text));
392        $this->nodestack->drop('php_inline');
393    }
394
395    public function phpblock($text)
396    {
397        $this->clearBlock();
398        $node = new Node('php_block');
399        $node->attr('class', 'php_block');
400
401        $this->nodestack->addTop($node);
402        $this->cdata(trim($text, "\n"));
403        $this->nodestack->drop('php_block');
404    }
405
406    /**
407     * @inheritDoc
408     */
409    public function rss($url, $params)
410    {
411        $this->clearBlock();
412        $node = new Node('rss');
413        $node->attr('url', hsc($url));
414        $node->attr('max', $params['max']);
415        $node->attr('reverse', (bool)$params['reverse']);
416        $node->attr('author', (bool)$params['author']);
417        $node->attr('date', (bool)$params['date']);
418        $node->attr('details', (bool)$params['details']);
419
420        if ($params['refresh'] % 86400 === 0) {
421            $refresh = $params['refresh'] / 86400 . 'd';
422        } elseif ($params['refresh'] % 3600 === 0) {
423            $refresh = $params['refresh'] / 3600 . 'h';
424        } else {
425            $refresh = $params['refresh'] / 60 . 'm';
426        }
427
428        $node->attr('refresh', trim($refresh));
429        $this->nodestack->add($node);
430    }
431
432
433    public function footnote_open()
434    {
435        $footnoteNode = new Node('footnote');
436        $this->nodestack->addTop($footnoteNode);
437        $this->nodestackBackup[] = $this->nodestack;
438        $this->nodestack = new NodeStack();
439    }
440
441    public function footnote_close()
442    {
443        $json = json_encode($this->nodestack->doc());
444        $this->nodestack = array_pop($this->nodestackBackup);
445        $this->nodestack->current()->attr('contentJSON', $json);
446        $this->nodestack->drop('footnote');
447    }
448
449    /**
450     * @inheritDoc
451     */
452    public function internalmedia(
453        $src,
454        $title = null,
455        $align = null,
456        $width = null,
457        $height = null,
458        $cache = null,
459        $linking = null
460    ) {
461
462        // FIXME how do we handle non-images, e.g. pdfs or audio?
463        ImageNode::render(
464            $this,
465            $src,
466            $title,
467            $align,
468            $width,
469            $height,
470            $cache,
471            $linking
472        );
473    }
474
475    /**
476     * @inheritDoc
477     */
478    public function externalmedia(
479        $src,
480        $title = null,
481        $align = null,
482        $width = null,
483        $height = null,
484        $cache = null,
485        $linking = null
486    ) {
487        ImageNode::render(
488            $this,
489            $src,
490            $title,
491            $align,
492            $width,
493            $height,
494            $cache,
495            $linking
496        );
497    }
498
499
500    public function locallink($hash, $name = null)
501    {
502        LocalLinkNode::render($this, $hash, $name);
503    }
504
505    /**
506     * @inheritDoc
507     */
508    public function internallink($id, $name = null)
509    {
510        InternalLinkNode::render($this, $id, $name);
511    }
512
513    public function externallink($link, $title = null)
514    {
515        ExternalLinkNode::render($this, $link, $title);
516    }
517
518    public function interwikilink($link, $title, $wikiName, $wikiUri)
519    {
520        InterwikiLinkNode::render($this, $title, $wikiName, $wikiUri);
521    }
522
523    public function emaillink($address, $name = null)
524    {
525        EmailLinkNode::render($this, $address, $name);
526    }
527
528    public function windowssharelink($link, $title = null)
529    {
530        WindowsShareLinkNode::render($this, $link, $title);
531    }
532
533    /** @inheritDoc */
534    public function linebreak()
535    {
536        $this->nodestack->add(new Node('hard_break'));
537    }
538
539    /** @inheritDoc */
540    public function hr()
541    {
542        $this->nodestack->add(new Node('horizontal_rule'));
543    }
544
545    public function plugin($name, $data, $state = '', $match = '')
546    {
547        if (empty($match)) {
548            return;
549        }
550        $eventData = [
551            'name' => $name,
552            'data' => $data,
553            'state' => $state,
554            'match' => $match,
555            'renderer' => $this,
556        ];
557        $event = new Event('PROSEMIRROR_RENDER_PLUGIN', $eventData);
558        if ($event->advise_before()) {
559            if ($this->nodestack->current()->getType() === 'paragraph') {
560                $nodetype = 'dwplugin_inline';
561            } else {
562                $nodetype = 'dwplugin_block';
563            }
564            $node = new Node($nodetype);
565            $node->attr('class', 'dwplugin');
566            $node->attr('data-pluginname', $name);
567            $this->nodestack->addTop($node);
568            $this->cdata($match);
569            $this->nodestack->drop($nodetype);
570        }
571    }
572
573    public function smiley($smiley)
574    {
575        if (array_key_exists($smiley, $this->smileys)) {
576            $node = new Node('smiley');
577            $node->attr('icon', $this->smileys[$smiley]);
578            $node->attr('syntax', $smiley);
579            $this->nodestack->add($node);
580        } else {
581            $this->cdata($smiley);
582        }
583    }
584
585    #region elements with no special WYSIWYG representation
586
587    /** @inheritDoc */
588    public function entity($entity)
589    {
590        $this->cdata($entity); // FIXME should we handle them special?
591    }
592
593    /** @inheritDoc */
594    public function multiplyentity($x, $y)
595    {
596        $this->cdata($x . 'x' . $y);
597    }
598
599    /** @inheritDoc */
600    public function acronym($acronym)
601    {
602        $this->cdata($acronym);
603    }
604
605    /** @inheritDoc */
606    public function apostrophe()
607    {
608        $this->cdata("'");
609    }
610
611    /** @inheritDoc */
612    public function singlequoteopening()
613    {
614        $this->cdata("'");
615    }
616
617    /** @inheritDoc */
618    public function singlequoteclosing()
619    {
620        $this->cdata("'");
621    }
622
623    /** @inheritDoc */
624    public function doublequoteopening()
625    {
626        $this->cdata('"');
627    }
628
629    /** @inheritDoc */
630    public function doublequoteclosing()
631    {
632        $this->cdata('"');
633    }
634
635    /** @inheritDoc */
636    public function camelcaselink($link)
637    {
638        $this->cdata($link); // FIXME should/could we decorate it?
639    }
640
641    #endregion
642
643    #region formatter marks
644
645    /** @inheritDoc */
646    public function strong_open()
647    {
648        $this->marks['strong'] = 1;
649    }
650
651    /** @inheritDoc */
652    public function strong_close()
653    {
654        if (isset($this->marks['strong'])) {
655            unset($this->marks['strong']);
656        }
657    }
658
659    /** @inheritDoc */
660    public function emphasis_open()
661    {
662        $this->marks['em'] = 1;
663    }
664
665    /** @inheritDoc */
666    public function emphasis_close()
667    {
668        if (isset($this->marks['em'])) {
669            unset($this->marks['em']);
670        }
671    }
672
673    /** @inheritdoc */
674    public function subscript_open()
675    {
676        $this->marks['subscript'] = 1;
677    }
678
679    /** @inheritDoc */
680    public function subscript_close()
681    {
682        if (isset($this->marks['subscript'])) {
683            unset($this->marks['subscript']);
684        }
685    }
686
687    /** @inheritdoc */
688    public function superscript_open()
689    {
690        $this->marks['superscript'] = 1;
691    }
692
693    /** @inheritDoc */
694    public function superscript_close()
695    {
696        if (isset($this->marks['superscript'])) {
697            unset($this->marks['superscript']);
698        }
699    }
700
701    /** @inheritDoc */
702    public function monospace_open()
703    {
704        $this->marks['code'] = 1;
705    }
706
707    /** @inheritDoc */
708    public function monospace_close()
709    {
710        if (isset($this->marks['code'])) {
711            unset($this->marks['code']);
712        }
713    }
714
715    /** @inheritDoc */
716    public function deleted_open()
717    {
718        $this->marks['deleted'] = 1;
719    }
720
721    /** @inheritDoc */
722    public function deleted_close()
723    {
724        if (isset($this->marks['deleted'])) {
725            unset($this->marks['deleted']);
726        }
727    }
728
729    /** @inheritDoc */
730    public function underline_open()
731    {
732        $this->marks['underline'] = 1;
733    }
734
735    /** @inheritDoc */
736    public function underline_close()
737    {
738        if (isset($this->marks['underline'])) {
739            unset($this->marks['underline']);
740        }
741    }
742
743
744    /** @inheritDoc */
745    public function unformatted($text)
746    {
747        $this->marks['unformatted'] = 1;
748        parent::unformatted($text);
749        unset($this->marks['unformatted']);
750    }
751
752
753    #endregion formatter marks
754}
755