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