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