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