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