xref: /plugin/mizarverifiabledocs/src/script.js (revision 8027593b4596d7ba62bcf14e4d9ed664e2d37487)
1/**
2 * DokuWiki Plugin Mizar Verifiable Docs (View Screen Script)
3 *
4 */
5
6// 必要なモジュールをインポート
7import { EditorState, Compartment, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state";
8import { EditorView, lineNumbers, showPanel, Decoration, ViewPlugin, keymap } from "@codemirror/view";
9import { LRLanguage, LanguageSupport, syntaxHighlighting, HighlightStyle, syntaxTree, foldNodeProp, foldInside, foldGutter, foldKeymap, codeFolding } from "@codemirror/language"; // ★ codeFoldingを追加
10import { parser } from "./mizar-parser.js";
11import { tags as t } from "@lezer/highlight";
12import { highlighting } from "./highlight.js"; // highlight.js からインポート
13
14// スタイルの定義
15const highlightStyle = HighlightStyle.define([
16    { tag: t.controlKeyword, class: "control-keyword" },
17    { tag: t.function(t.keyword), class: "function-keyword" },
18    { tag: t.keyword, class: "general-keyword" },
19    { tag: t.typeName, class: "type-name" },
20    { tag: t.meta, class: "meta-info" },
21    { tag: t.lineComment, class: "line-comment" },
22    { tag: t.paren, class: "paren" },
23    { tag: t.brace, class: "brace" },
24    { tag: t.squareBracket, class: "square-bracket" },
25]);
26
27// パーサー設定: 折りたたみ可能なノードを指定
28let parserWithMetadata = parser.configure({
29    props: [
30        highlighting,
31        foldNodeProp.add({
32            "DefinitionBlock": foldInside,
33            "Proof": foldInside,
34            "NowBlock": foldInside,
35            "HerebyBlock": foldInside
36        })
37    ]
38});
39
40// 言語サポートの定義
41const mizarLanguage = LRLanguage.define({
42    parser: parserWithMetadata
43});
44
45function mizar() {
46    return new LanguageSupport(mizarLanguage);
47}
48
49function bracketHighlighter() {
50    return ViewPlugin.fromClass(class {
51      constructor(view) {
52        this.decorations = this.buildDecorations(view);
53      }
54
55      update(update) {
56        if (update.docChanged || update.viewportChanged) {
57          this.decorations = this.buildDecorations(update.view);
58        }
59      }
60
61      buildDecorations(view) {
62        const builder = new RangeSetBuilder();
63        const stack = [];
64        const colors = ['bracket-color-0', 'bracket-color-1', 'bracket-color-2', 'bracket-color-3', 'bracket-color-4'];
65
66        syntaxTree(view.state).iterate({
67            enter: ({ type, from, to }) => {
68                if (type.is('OpenParen') || type.is('OpenBrace') || type.is('OpenBracket')) {
69                    stack.push({ type, from, to });
70                    const level = stack.length % colors.length;
71                    builder.add(from, to, Decoration.mark({ class: colors[level] }));
72                } else if (type.is('CloseParen') || type.is('CloseBrace') || type.is('CloseBracket')) {
73                    const open = stack.pop();
74                    if (open) {
75                        const level = (stack.length + 1) % colors.length;
76                        builder.add(from, to, Decoration.mark({ class: colors[level] }));
77                    }
78                }
79            }
80        });
81        return builder.finish();
82      }
83    }, {
84      decorations: v => v.decorations
85    });
86}
87
88const editableCompartment = new Compartment();  // 共有Compartment
89// グローバルオブジェクトとしてエディタを管理
90const editors = window.editors = window.editors || {};
91const lineNumberConfigs = window.lineNumberConfigs = window.lineNumberConfigs || {};
92const editorOrder = window.editorOrder = window.editorOrder || [];
93
94// エラーデコレーションのエフェクトと状態フィールドを定義
95const errorDecorationEffect = StateEffect.define();
96const errorDecorationsField = StateField.define({
97    create: () => Decoration.none,
98    update(decorations, tr) {
99        decorations = decorations.map(tr.changes);
100        for (let effect of tr.effects) {
101            if (effect.is(errorDecorationEffect)) {
102                decorations = decorations.update({ add: effect.value });
103            }
104        }
105        return decorations;
106    },
107    provide: f => EditorView.decorations.from(f)
108});
109
110// 表示切替関数を修正
111function toggleMizarEditor(wrapper, hide) {
112    const elementsToToggle = wrapper.querySelectorAll(
113        '.editor-container, .copy-button, .edit-button, .reset-button, .compile-button'
114    );
115
116    elementsToToggle.forEach(el => {
117        el.style.display = hide ? 'none' : '';
118    });
119
120    // Show/Hide ボタンの表示を切り替え
121    const hideButton = wrapper.querySelector('.hide-button');
122    const showButton = wrapper.querySelector('.show-button');
123    if (hideButton && showButton) {
124        hideButton.style.display = hide ? 'none' : 'inline';
125        showButton.style.display = hide ? 'inline' : 'none';
126    }
127}
128
129// DOMContentLoaded時の初期化
130document.addEventListener("DOMContentLoaded", function () {
131    window.mizarInitialized = true;
132
133    const wrappers = document.querySelectorAll('.mizarWrapper');
134    if (wrappers.length === 0) {
135        console.warn("No Mizar blocks found.");
136        return;
137    }
138
139    wrappers.forEach((block) => {
140        const mizarId = block.id.replace('mizarBlock', '');
141        if (!block.querySelector('.editor-container').firstChild) {
142            setupMizarBlock(block, mizarId);
143        }
144    });
145
146    // Hide ボタンのクリックハンドラ
147    const hideButtons = document.querySelectorAll('.hide-button');
148    hideButtons.forEach((button) => {
149        button.addEventListener('click', () => {
150            const parentWrapper = button.closest('.mizarWrapper');
151            if (!parentWrapper) return;
152            toggleMizarEditor(parentWrapper, true);
153        });
154    });
155
156    // Show ボタンのクリックハンドラ
157    const showButtons = document.querySelectorAll('.show-button');
158    showButtons.forEach((button) => {
159        button.addEventListener('click', () => {
160            const parentWrapper = button.closest('.mizarWrapper');
161            if (!parentWrapper) return;
162            toggleMizarEditor(parentWrapper, false);
163        });
164    });
165
166    const hideAllButton = document.getElementById('hideAllButton');
167    const showAllButton = document.getElementById('showAllButton');
168    const resetAllButton = document.getElementById('resetAllButton');  // ★ 追加
169
170    if (hideAllButton && showAllButton) {
171        hideAllButton.addEventListener('click', (e) => {
172            // Hide All 処理
173            toggleAllWrappers(true); // 全て hide
174            hideAllButton.style.display = 'none';
175            showAllButton.style.display = '';
176            e.target.blur(); // フォーカスを外す
177        });
178
179        showAllButton.addEventListener('click', (e) => {
180            // Show All 処理
181            toggleAllWrappers(false); // 全て show
182            showAllButton.style.display = 'none';
183            hideAllButton.style.display = '';
184            e.target.blur(); // フォーカスを外す
185        });
186    }
187
188    function toggleAllWrappers(hide) {
189        const allWrappers = document.querySelectorAll('.mizarWrapper');
190        allWrappers.forEach((wrapper) => {
191            toggleMizarEditor(wrapper, hide);
192        });
193    }
194
195    // ★ Reset All ボタンのイベントリスナーを追加
196    if (resetAllButton) {
197        resetAllButton.addEventListener('click', (e) => {
198            const allWrappers = document.querySelectorAll('.mizarWrapper');
199            allWrappers.forEach(wrapper => {
200                const resetBtn = wrapper.querySelector('button[id^="resetButton"]');
201                if (resetBtn) {
202                    // resetBtn の clickイベントを強制的に発火
203                    resetBtn.click();
204                }
205            });
206            // クリックされた要素(Reset All ボタン)からフォーカスを外す
207            e.target.blur();
208        });
209    }
210}, { once: true });
211
212// パネルの表示を制御するエフェクトとステートフィールド
213const toggleErrorPanel = StateEffect.define();
214const errorPanelState = StateField.define({
215    create: () => null,
216    update(value, tr) {
217        for (let e of tr.effects) {
218            if (e.is(toggleErrorPanel)) value = e.value;
219        }
220        return value;
221    },
222    provide: f => showPanel.from(f, val => val ? createPanel(val) : null)
223});
224
225function createPanel(content) {
226    return (_view) => {
227        let dom = document.createElement("div");
228        dom.innerHTML = content;
229        dom.className = "cm-error-panel";
230        return { dom };
231    };
232}
233
234function calculateStartLineNumber(targetMizarId) {
235    let totalLines = 1;
236    for (let mizarId of editorOrder) {
237        if (mizarId === targetMizarId) {
238            break;
239        }
240        totalLines += editors[mizarId].state.doc.lines;
241    }
242    return totalLines;
243}
244
245function adjustLineNumbersForAllEditors() {
246    let totalLines = 1;
247    for (let mizarId of editorOrder) {
248        const editor = editors[mizarId];
249        const startLine = totalLines;
250        const lineNumberExtension = lineNumbers({
251            formatNumber: number => `${number + startLine - 1}`
252        });
253        editor.dispatch({
254            effects: lineNumberConfigs[mizarId].reconfigure(lineNumberExtension)
255        });
256        totalLines += editor.state.doc.lines;
257    }
258}
259
260function getEditorForGlobalLine(globalLine) {
261    let currentLine = 1;
262    for (let mizarId of editorOrder) {
263        const editor = editors[mizarId];
264        const editorLines = editor.state.doc.lines;
265        if (globalLine >= currentLine && globalLine < currentLine + editorLines) {
266            return { editor, localLine: globalLine - currentLine + 1 };
267        }
268        currentLine += editorLines;
269    }
270    return null;
271}
272
273// エディタの初期設定時にリスナーを追加
274function setupMizarBlock(mizarBlock, mizarId) {
275    const editorContainer = mizarBlock.querySelector('.editor-container');
276    editorContainer.id = `editorContainer${mizarId}`;
277    const editorContent = editorContainer.getAttribute('data-content');
278
279    const tempDiv = document.createElement('div');
280    tempDiv.innerHTML = editorContent;
281    const decodedContent = tempDiv.textContent || tempDiv.innerText || "";
282
283    const lineNumberConfig = new Compartment();
284    lineNumberConfigs[mizarId] = lineNumberConfig;
285
286    // codeFolding()を追加して折りたたみを有効化
287    const editor = new EditorView({
288        state: EditorState.create({
289            doc: decodedContent,
290            extensions: [
291                lineNumberConfig.of(lineNumbers({ formatNumber: number => `${number + calculateStartLineNumber(mizarId) - 1}` })),
292                editableCompartment.of(EditorView.editable.of(false)),
293                syntaxHighlighting(highlightStyle),
294                codeFolding(),       // ★ 追加
295                foldGutter(),        // ★ 折りたたみガター
296                keymap.of(foldKeymap), // ★ 折りたたみ用キーマップ
297                bracketHighlighter(),
298                EditorView.updateListener.of(update => {
299                    if (update.docChanged) {
300                        adjustLineNumbersForAllEditors();
301                    }
302                }),
303                errorPanelState,
304                errorDecorationsField,
305                mizar()
306            ]
307        }),
308        parent: editorContainer
309    });
310    window.editors[mizarId] = editor;
311    editorOrder.push(mizarId);
312
313    const editButton = mizarBlock.querySelector('button[id^="editButton"]');
314    const compileButton = mizarBlock.querySelector('button[id^="compileButton"]');
315    const resetButton = mizarBlock.querySelector('button[id^="resetButton"]');
316
317    editButton.addEventListener('click', () => {
318        editor.dispatch({
319            effects: editableCompartment.reconfigure(EditorView.editable.of(true))
320        });
321        editButton.style.display = 'none';
322        compileButton.style.display = 'inline';
323        resetButton.style.display = 'inline';
324    });
325
326    compileButton.addEventListener('click', () => {
327        if (mizarBlock.isRequestInProgress) {
328            return;
329        }
330        mizarBlock.isRequestInProgress = true;
331        startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId);
332    });
333
334    resetButton.addEventListener('click', async () => {
335        const initialContent = editorContainer.getAttribute('data-content');
336        const tempDiv = document.createElement('div');
337        tempDiv.innerHTML = initialContent;
338        const decodedContent = tempDiv.textContent || tempDiv.innerText || "";
339
340        editor.dispatch({
341            changes: { from: 0, to: editor.state.doc.length, insert: decodedContent }
342        });
343
344        editor.dispatch({
345            effects: editableCompartment.reconfigure(EditorView.editable.of(false))
346        });
347
348        editButton.style.display = 'inline';
349        compileButton.style.display = 'none';
350        resetButton.style.display = 'none';
351
352        editor.dispatch({
353            effects: toggleErrorPanel.of(null)
354        });
355
356        editor.dispatch({
357            effects: errorDecorationEffect.of([])
358        });
359
360        try {
361            const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=clear_temp_files", {
362                method: "POST",
363                headers: {
364                    "Content-Type": "application/x-www-form-urlencoded"
365                }
366            });
367
368            const text = await response.text();
369            let data;
370            try {
371                data = JSON.parse(text);
372            } catch (error) {
373                throw new Error("Server returned a non-JSON response: " + text);
374            }
375
376            if (data.success) {
377                console.log(data.message);
378            } else {
379                console.error("Failed to clear temporary files:", data.message);
380            }
381        } catch (error) {
382            console.error("Error clearing temporary files:", error);
383        }
384    });
385
386    // Enterキーで行追加
387    editor.dom.addEventListener('keydown', (e) => {
388        if (e.key === 'Enter' && editor.state.facet(EditorView.editable)) {
389            e.preventDefault();
390            const transaction = editor.state.update({
391                changes: { from: editor.state.selection.main.head, insert: '\n' }
392            });
393            editor.dispatch(transaction);
394        }
395    });
396}
397
398function scrollToLine(editor, line) {
399    const lineInfo = editor.state.doc.line(line);
400    editor.dispatch({
401        scrollIntoView: { from: lineInfo.from, to: lineInfo.to }
402    });
403}
404
405async function startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId) {
406    if (mizarBlock.eventSource) {
407        mizarBlock.eventSource.close();
408    }
409
410    const combinedContent = getCombinedContentUntil(mizarBlock);
411    const data = "content=" + encodeURIComponent(combinedContent);
412
413    try {
414        const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=view_compile", {
415            method: "POST",
416            headers: {
417                "Content-Type": "application/x-www-form-urlencoded"
418            },
419            body: data
420        });
421
422        if (!response.ok) {
423            throw new Error("Network response was not ok");
424        }
425
426        mizarBlock.eventSource = new EventSource(DOKU_BASE + "lib/exe/ajax.php?call=view_sse&" + data);
427
428        mizarBlock.eventSource.onmessage = function(event) {
429            const editor = editors[mizarId];
430            let content = editor.state.field(errorPanelState) || '';
431            content += event.data + '<br>';
432            editor.dispatch({
433                effects: toggleErrorPanel.of(content)
434            });
435
436            if (content.includes('❌')) {
437                editor.dom.querySelector('.cm-error-panel').style.backgroundColor = '#fcc';
438            } else {
439                editor.dom.querySelector('.cm-error-panel').style.backgroundColor = '#ccffcc';
440            }
441        };
442
443        mizarBlock.eventSource.addEventListener('compileErrors', function(event) {
444            try {
445                const errors = JSON.parse(event.data);
446                let errorContent = editors[mizarId].state.field(errorPanelState) || '';
447                const decorationsPerEditor = {};
448
449                errors.forEach(function(error) {
450                    const { line, column, message } = error;
451                    const link = `<a href="#" class="error-link" data-line="${line}" data-column="${column}">[Ln ${line}, Col ${column}]</a>`;
452                    errorContent += `❌ ${message} ${link}<br>`;
453
454                    const editorInfo = getEditorForGlobalLine(line);
455                    if (editorInfo) {
456                        const { editor, localLine } = editorInfo;
457                        const lineInfo = editor.state.doc.line(localLine);
458                        const from = lineInfo.from + (column - 1);
459                        const to = from + 1;
460
461                        if (from >= lineInfo.from && to <= lineInfo.to) {
462                            const deco = Decoration.mark({
463                                class: "error-underline",
464                                attributes: { title: `${message} (Ln ${line}, Col ${column})` }
465                            }).range(from, to);
466                            const editorId = Object.keys(editors).find(key => editors[key] === editor);
467                            if (!decorationsPerEditor[editorId]) {
468                                decorationsPerEditor[editorId] = [];
469                            }
470                            decorationsPerEditor[editorId].push(deco);
471                        }
472                    }
473                });
474
475                for (let editorId in decorationsPerEditor) {
476                    const editor = editors[editorId];
477                    const decorations = decorationsPerEditor[editorId];
478                    if (decorations.length > 0) {
479                        editor.dispatch({
480                            effects: errorDecorationEffect.of(decorations.sort((a, b) => a.from - b.from))
481                        });
482                    }
483                }
484
485                editors[mizarId].dispatch({
486                    effects: [
487                        toggleErrorPanel.of(errorContent)
488                    ]
489                });
490
491                if (errorContent.includes('❌')) {
492                    editors[mizarId].dom.querySelector('.cm-error-panel').style.backgroundColor = '#fcc';
493                } else {
494                    editors[mizarId].dom.querySelector('.cm-error-panel').style.backgroundColor = '#ccffcc';
495                }
496
497                const errorPanel = editors[mizarId].dom.querySelector('.cm-error-panel');
498                errorPanel.querySelectorAll('.error-link').forEach(link => {
499                    link.addEventListener('click', function(e) {
500                        e.preventDefault();
501                        const line = parseInt(link.getAttribute('data-line'), 10);
502                        const editorInfo = getEditorForGlobalLine(line);
503                        if (editorInfo) {
504                            const { editor, localLine } = editorInfo;
505                            scrollToLine(editor, localLine);
506                        }
507                    });
508                });
509            } catch (e) {
510                console.error('Failed to parse error data:', e);
511                console.error('Event data:', event.data);
512            }
513        });
514
515        mizarBlock.eventSource.onerror = () => {
516            mizarBlock.eventSource.close();
517            mizarBlock.isRequestInProgress = false;
518            finalizeCompilation(mizarBlock);
519        };
520    } catch (error) {
521        console.error("Fetch error: ", error);
522        mizarBlock.isRequestInProgress = false;
523    }
524}
525
526function getCombinedContentUntil(mizarBlock) {
527    let combinedContent = '';
528    const mizarBlocks = document.querySelectorAll('.mizarWrapper');
529    const blockIndex = Array.from(mizarBlocks).indexOf(mizarBlock);
530
531    for (let i = 0; i <= blockIndex; i++) {
532        const mizarId = editorOrder[i];
533        const editor = editors[mizarId];
534        if (editor && editor.state) {
535            const blockContent = editor.state.doc.toString();
536            combinedContent += blockContent + "\n";
537        }
538    }
539
540    return combinedContent.trim();
541}
542
543function finalizeCompilation(mizarBlock) {
544    const compileButton = mizarBlock.querySelector('[id^="compileButton"]');
545    const resetButton = mizarBlock.querySelector('[id^="resetButton"]');
546
547    compileButton.style.display = 'none';
548    resetButton.style.display = 'inline-block';
549
550    mizarBlock.isRequestInProgress = false;
551}
552
553window.createMizarFile = async function(filename) {
554    const combinedContent = collectMizarContents();
555
556    if (!combinedContent) {
557        console.error('Error: Combined content is empty.');
558        return;
559    }
560
561    if (!filename.endsWith('.miz')) {
562        filename += '.miz';
563    }
564
565    try {
566        const response = await fetch(DOKU_BASE + 'lib/exe/ajax.php?call=create_combined_file', {
567            method: 'POST',
568            headers: {
569                'Content-Type': 'application/x-www-form-urlencoded'
570            },
571            body: 'content=' + encodeURIComponent(combinedContent) + '&filename=' + encodeURIComponent(filename)
572        });
573
574        if (!response.ok) {
575            console.error('Failed to create file: Network response was not ok');
576            return;
577        }
578
579        const data = await response.json();
580        if (data.success) {
581            console.log('File created successfully:', filename);
582
583            const blob = new Blob([data.data.content], { type: 'application/octet-stream' });
584            const url = URL.createObjectURL(blob);
585
586            const contentWindow = window.open('', '_blank');
587            contentWindow.document.write('<pre>' + data.data.content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>');
588            contentWindow.document.title = filename;
589
590            const downloadLink = contentWindow.document.createElement('a');
591            downloadLink.href = url;
592            downloadLink.download = filename;
593            downloadLink.textContent = '⬇️ Click here to download the file';
594            downloadLink.style.display = 'block';
595            downloadLink.style.marginTop = '10px';
596            contentWindow.document.body.appendChild(downloadLink);
597
598            contentWindow.addEventListener('unload', () => {
599                URL.revokeObjectURL(url);
600            });
601        } else {
602            console.error('Failed to create file:', data.message);
603        }
604    } catch (error) {
605        console.error('Error creating file:', error);
606    }
607};
608
609function collectMizarContents() {
610    let combinedContent = '';
611    for (let mizarId of editorOrder) {
612        const editor = editors[mizarId];
613        if (editor && editor.state) {
614            combinedContent += editor.state.doc.toString() + '\n';
615        }
616    }
617    return combinedContent;
618}
619
620document.addEventListener("DOMContentLoaded", () => {
621    const copyButtons = document.querySelectorAll('.copy-button');
622
623    copyButtons.forEach((button) => {
624        button.addEventListener('click', (event) => {
625            const buttonElement = event.currentTarget;
626            const mizarIdRaw = buttonElement.dataset.mizarid;
627            const mizarId = mizarIdRaw.replace('mizarBlock', '');
628
629            if (!editors[mizarId]) {
630                console.error('エディタが見つかりません: ', mizarIdRaw);
631                return;
632            }
633
634            const editor = editors[mizarId];
635            const content = editor.state.doc.toString();
636
637            navigator.clipboard.writeText(content).then(() => {
638                buttonElement.textContent = 'Copied!';
639                buttonElement.disabled = true;
640
641                setTimeout(() => {
642                    buttonElement.textContent = 'Copy';
643                    buttonElement.disabled = false;
644                }, 2000);
645            }).catch((err) => {
646                console.error('コピーに失敗しました: ', err);
647            });
648        });
649    });
650});
651