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