xref: /plugin/mizarverifiabledocs/src/script.js (revision 4f65af2c978c8b810c87fd11fcd551c84c6abad5)
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
166    if (hideAllButton && showAllButton) {
167        hideAllButton.addEventListener('click', (e) => {
168            // Hide All 処理
169            toggleAllWrappers(true); // 全て hide
170            hideAllButton.style.display = 'none';
171            showAllButton.style.display = '';
172            e.target.blur(); // フォーカスを外す
173        });
174
175        showAllButton.addEventListener('click', (e) => {
176            // Show All 処理
177            toggleAllWrappers(false); // 全て show
178            showAllButton.style.display = 'none';
179            hideAllButton.style.display = '';
180            e.target.blur(); // フォーカスを外す
181        });
182    }
183
184    function toggleAllWrappers(hide) {
185        const allWrappers = document.querySelectorAll('.mizarWrapper');
186        allWrappers.forEach((wrapper) => {
187            toggleMizarEditor(wrapper, hide);
188        });
189    }
190}, { once: true });
191
192// パネルの表示を制御するエフェクトとステートフィールド
193const toggleErrorPanel = StateEffect.define();
194const errorPanelState = StateField.define({
195    create: () => null,
196    update(value, tr) {
197        for (let e of tr.effects) {
198            if (e.is(toggleErrorPanel)) value = e.value;
199        }
200        return value;
201    },
202    provide: f => showPanel.from(f, val => val ? createPanel(val) : null)
203});
204
205function createPanel(content) {
206    return (_view) => {
207        let dom = document.createElement("div");
208        dom.innerHTML = content;
209        dom.className = "cm-error-panel";
210        return { dom };
211    };
212}
213
214function calculateStartLineNumber(targetMizarId) {
215    let totalLines = 1;
216    for (let mizarId of editorOrder) {
217        if (mizarId === targetMizarId) {
218            break;
219        }
220        totalLines += editors[mizarId].state.doc.lines;
221    }
222    return totalLines;
223}
224
225function adjustLineNumbersForAllEditors() {
226    let totalLines = 1;
227    for (let mizarId of editorOrder) {
228        const editor = editors[mizarId];
229        const startLine = totalLines;
230        const lineNumberExtension = lineNumbers({
231            formatNumber: number => `${number + startLine - 1}`
232        });
233        editor.dispatch({
234            effects: lineNumberConfigs[mizarId].reconfigure(lineNumberExtension)
235        });
236        totalLines += editor.state.doc.lines;
237    }
238}
239
240function getEditorForGlobalLine(globalLine) {
241    let currentLine = 1;
242    for (let mizarId of editorOrder) {
243        const editor = editors[mizarId];
244        const editorLines = editor.state.doc.lines;
245        if (globalLine >= currentLine && globalLine < currentLine + editorLines) {
246            return { editor, localLine: globalLine - currentLine + 1 };
247        }
248        currentLine += editorLines;
249    }
250    return null;
251}
252
253// エディタの初期設定時にリスナーを追加
254function setupMizarBlock(mizarBlock, mizarId) {
255    const editorContainer = mizarBlock.querySelector('.editor-container');
256    editorContainer.id = `editorContainer${mizarId}`;
257    const editorContent = editorContainer.getAttribute('data-content');
258
259    const tempDiv = document.createElement('div');
260    tempDiv.innerHTML = editorContent;
261    const decodedContent = tempDiv.textContent || tempDiv.innerText || "";
262
263    const lineNumberConfig = new Compartment();
264    lineNumberConfigs[mizarId] = lineNumberConfig;
265
266    // codeFolding()を追加して折りたたみを有効化
267    const editor = new EditorView({
268        state: EditorState.create({
269            doc: decodedContent,
270            extensions: [
271                lineNumberConfig.of(lineNumbers({ formatNumber: number => `${number + calculateStartLineNumber(mizarId) - 1}` })),
272                editableCompartment.of(EditorView.editable.of(false)),
273                syntaxHighlighting(highlightStyle),
274                codeFolding(),       // ★ 追加
275                foldGutter(),        // ★ 折りたたみガター
276                keymap.of(foldKeymap), // ★ 折りたたみ用キーマップ
277                bracketHighlighter(),
278                EditorView.updateListener.of(update => {
279                    if (update.docChanged) {
280                        adjustLineNumbersForAllEditors();
281                    }
282                }),
283                errorPanelState,
284                errorDecorationsField,
285                mizar()
286            ]
287        }),
288        parent: editorContainer
289    });
290    window.editors[mizarId] = editor;
291    editorOrder.push(mizarId);
292
293    const editButton = mizarBlock.querySelector('button[id^="editButton"]');
294    const compileButton = mizarBlock.querySelector('button[id^="compileButton"]');
295    const resetButton = mizarBlock.querySelector('button[id^="resetButton"]');
296
297    editButton.addEventListener('click', () => {
298        editor.dispatch({
299            effects: editableCompartment.reconfigure(EditorView.editable.of(true))
300        });
301        editButton.style.display = 'none';
302        compileButton.style.display = 'inline';
303        resetButton.style.display = 'inline';
304    });
305
306    compileButton.addEventListener('click', () => {
307        if (mizarBlock.isRequestInProgress) {
308            return;
309        }
310        mizarBlock.isRequestInProgress = true;
311        startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId);
312    });
313
314    resetButton.addEventListener('click', async () => {
315        const initialContent = editorContainer.getAttribute('data-content');
316        const tempDiv = document.createElement('div');
317        tempDiv.innerHTML = initialContent;
318        const decodedContent = tempDiv.textContent || tempDiv.innerText || "";
319
320        editor.dispatch({
321            changes: { from: 0, to: editor.state.doc.length, insert: decodedContent }
322        });
323
324        editor.dispatch({
325            effects: editableCompartment.reconfigure(EditorView.editable.of(false))
326        });
327
328        editButton.style.display = 'inline';
329        compileButton.style.display = 'none';
330        resetButton.style.display = 'none';
331
332        editor.dispatch({
333            effects: toggleErrorPanel.of(null)
334        });
335
336        editor.dispatch({
337            effects: errorDecorationEffect.of([])
338        });
339
340        try {
341            const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=clear_temp_files", {
342                method: "POST",
343                headers: {
344                    "Content-Type": "application/x-www-form-urlencoded"
345                }
346            });
347
348            const text = await response.text();
349            let data;
350            try {
351                data = JSON.parse(text);
352            } catch (error) {
353                throw new Error("Server returned a non-JSON response: " + text);
354            }
355
356            if (data.success) {
357                console.log(data.message);
358            } else {
359                console.error("Failed to clear temporary files:", data.message);
360            }
361        } catch (error) {
362            console.error("Error clearing temporary files:", error);
363        }
364    });
365
366    // Enterキーで行追加
367    editor.dom.addEventListener('keydown', (e) => {
368        if (e.key === 'Enter' && editor.state.facet(EditorView.editable)) {
369            e.preventDefault();
370            const transaction = editor.state.update({
371                changes: { from: editor.state.selection.main.head, insert: '\n' }
372            });
373            editor.dispatch(transaction);
374        }
375    });
376}
377
378function scrollToLine(editor, line) {
379    const lineInfo = editor.state.doc.line(line);
380    editor.dispatch({
381        scrollIntoView: { from: lineInfo.from, to: lineInfo.to }
382    });
383}
384
385async function startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId) {
386    if (mizarBlock.eventSource) {
387        mizarBlock.eventSource.close();
388    }
389
390    const combinedContent = getCombinedContentUntil(mizarBlock);
391    const data = "content=" + encodeURIComponent(combinedContent);
392
393    try {
394        const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=view_compile", {
395            method: "POST",
396            headers: {
397                "Content-Type": "application/x-www-form-urlencoded"
398            },
399            body: data
400        });
401
402        if (!response.ok) {
403            throw new Error("Network response was not ok");
404        }
405
406        mizarBlock.eventSource = new EventSource(DOKU_BASE + "lib/exe/ajax.php?call=view_sse&" + data);
407
408        mizarBlock.eventSource.onmessage = function(event) {
409            const editor = editors[mizarId];
410            let content = editor.state.field(errorPanelState) || '';
411            content += event.data + '<br>';
412            editor.dispatch({
413                effects: toggleErrorPanel.of(content)
414            });
415
416            if (content.includes('❌')) {
417                editor.dom.querySelector('.cm-error-panel').style.backgroundColor = '#fcc';
418            } else {
419                editor.dom.querySelector('.cm-error-panel').style.backgroundColor = '#ccffcc';
420            }
421        };
422
423        mizarBlock.eventSource.addEventListener('compileErrors', function(event) {
424            try {
425                const errors = JSON.parse(event.data);
426                let errorContent = editors[mizarId].state.field(errorPanelState) || '';
427                const decorationsPerEditor = {};
428
429                errors.forEach(function(error) {
430                    const { line, column, message } = error;
431                    const link = `<a href="#" class="error-link" data-line="${line}" data-column="${column}">[Ln ${line}, Col ${column}]</a>`;
432                    errorContent += `❌ ${message} ${link}<br>`;
433
434                    const editorInfo = getEditorForGlobalLine(line);
435                    if (editorInfo) {
436                        const { editor, localLine } = editorInfo;
437                        const lineInfo = editor.state.doc.line(localLine);
438                        const from = lineInfo.from + (column - 1);
439                        const to = from + 1;
440
441                        if (from >= lineInfo.from && to <= lineInfo.to) {
442                            const deco = Decoration.mark({
443                                class: "error-underline",
444                                attributes: { title: `${message} (Ln ${line}, Col ${column})` }
445                            }).range(from, to);
446                            const editorId = Object.keys(editors).find(key => editors[key] === editor);
447                            if (!decorationsPerEditor[editorId]) {
448                                decorationsPerEditor[editorId] = [];
449                            }
450                            decorationsPerEditor[editorId].push(deco);
451                        }
452                    }
453                });
454
455                for (let editorId in decorationsPerEditor) {
456                    const editor = editors[editorId];
457                    const decorations = decorationsPerEditor[editorId];
458                    if (decorations.length > 0) {
459                        editor.dispatch({
460                            effects: errorDecorationEffect.of(decorations.sort((a, b) => a.from - b.from))
461                        });
462                    }
463                }
464
465                editors[mizarId].dispatch({
466                    effects: [
467                        toggleErrorPanel.of(errorContent)
468                    ]
469                });
470
471                if (errorContent.includes('❌')) {
472                    editors[mizarId].dom.querySelector('.cm-error-panel').style.backgroundColor = '#fcc';
473                } else {
474                    editors[mizarId].dom.querySelector('.cm-error-panel').style.backgroundColor = '#ccffcc';
475                }
476
477                const errorPanel = editors[mizarId].dom.querySelector('.cm-error-panel');
478                errorPanel.querySelectorAll('.error-link').forEach(link => {
479                    link.addEventListener('click', function(e) {
480                        e.preventDefault();
481                        const line = parseInt(link.getAttribute('data-line'), 10);
482                        const editorInfo = getEditorForGlobalLine(line);
483                        if (editorInfo) {
484                            const { editor, localLine } = editorInfo;
485                            scrollToLine(editor, localLine);
486                        }
487                    });
488                });
489            } catch (e) {
490                console.error('Failed to parse error data:', e);
491                console.error('Event data:', event.data);
492            }
493        });
494
495        mizarBlock.eventSource.onerror = () => {
496            mizarBlock.eventSource.close();
497            mizarBlock.isRequestInProgress = false;
498            finalizeCompilation(mizarBlock);
499        };
500    } catch (error) {
501        console.error("Fetch error: ", error);
502        mizarBlock.isRequestInProgress = false;
503    }
504}
505
506function getCombinedContentUntil(mizarBlock) {
507    let combinedContent = '';
508    const mizarBlocks = document.querySelectorAll('.mizarWrapper');
509    const blockIndex = Array.from(mizarBlocks).indexOf(mizarBlock);
510
511    for (let i = 0; i <= blockIndex; i++) {
512        const mizarId = editorOrder[i];
513        const editor = editors[mizarId];
514        if (editor && editor.state) {
515            const blockContent = editor.state.doc.toString();
516            combinedContent += blockContent + "\n";
517        }
518    }
519
520    return combinedContent.trim();
521}
522
523function finalizeCompilation(mizarBlock) {
524    const compileButton = mizarBlock.querySelector('[id^="compileButton"]');
525    const resetButton = mizarBlock.querySelector('[id^="resetButton"]');
526
527    compileButton.style.display = 'none';
528    resetButton.style.display = 'inline-block';
529
530    mizarBlock.isRequestInProgress = false;
531}
532
533window.createMizarFile = async function(filename) {
534    const combinedContent = collectMizarContents();
535
536    if (!combinedContent) {
537        console.error('Error: Combined content is empty.');
538        return;
539    }
540
541    if (!filename.endsWith('.miz')) {
542        filename += '.miz';
543    }
544
545    try {
546        const response = await fetch(DOKU_BASE + 'lib/exe/ajax.php?call=create_combined_file', {
547            method: 'POST',
548            headers: {
549                'Content-Type': 'application/x-www-form-urlencoded'
550            },
551            body: 'content=' + encodeURIComponent(combinedContent) + '&filename=' + encodeURIComponent(filename)
552        });
553
554        if (!response.ok) {
555            console.error('Failed to create file: Network response was not ok');
556            return;
557        }
558
559        const data = await response.json();
560        if (data.success) {
561            console.log('File created successfully:', filename);
562
563            const blob = new Blob([data.data.content], { type: 'application/octet-stream' });
564            const url = URL.createObjectURL(blob);
565
566            const contentWindow = window.open('', '_blank');
567            contentWindow.document.write('<pre>' + data.data.content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>');
568            contentWindow.document.title = filename;
569
570            const downloadLink = contentWindow.document.createElement('a');
571            downloadLink.href = url;
572            downloadLink.download = filename;
573            downloadLink.textContent = '⬇️ Click here to download the file';
574            downloadLink.style.display = 'block';
575            downloadLink.style.marginTop = '10px';
576            contentWindow.document.body.appendChild(downloadLink);
577
578            contentWindow.addEventListener('unload', () => {
579                URL.revokeObjectURL(url);
580            });
581        } else {
582            console.error('Failed to create file:', data.message);
583        }
584    } catch (error) {
585        console.error('Error creating file:', error);
586    }
587};
588
589function collectMizarContents() {
590    let combinedContent = '';
591    for (let mizarId of editorOrder) {
592        const editor = editors[mizarId];
593        if (editor && editor.state) {
594            combinedContent += editor.state.doc.toString() + '\n';
595        }
596    }
597    return combinedContent;
598}
599
600document.addEventListener("DOMContentLoaded", () => {
601    const copyButtons = document.querySelectorAll('.copy-button');
602
603    copyButtons.forEach((button) => {
604        button.addEventListener('click', (event) => {
605            const buttonElement = event.currentTarget;
606            const mizarIdRaw = buttonElement.dataset.mizarid;
607            const mizarId = mizarIdRaw.replace('mizarBlock', '');
608
609            if (!editors[mizarId]) {
610                console.error('エディタが見つかりません: ', mizarIdRaw);
611                return;
612            }
613
614            const editor = editors[mizarId];
615            const content = editor.state.doc.toString();
616
617            navigator.clipboard.writeText(content).then(() => {
618                buttonElement.textContent = 'Copied!';
619                buttonElement.disabled = true;
620
621                setTimeout(() => {
622                    buttonElement.textContent = 'Copy';
623                    buttonElement.disabled = false;
624                }, 2000);
625            }).catch((err) => {
626                console.error('コピーに失敗しました: ', err);
627            });
628        });
629    });
630});
631