xref: /plugin/mizarverifiabledocs/src/script.js (revision 22b742c87b8bbc72be9bb25ed73d5018b9a9eae0)
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 { defaultKeymap, insertNewlineAndIndent } from "@codemirror/commands";
10import {
11  LRLanguage,
12  LanguageSupport,
13  syntaxHighlighting,
14  HighlightStyle,
15  syntaxTree,
16  foldNodeProp,
17  foldInside,
18  foldGutter,
19  foldKeymap,
20  codeFolding,
21  indentNodeProp,
22} from "@codemirror/language"; // ★ codeFoldingなど
23import { parser } from "./mizar-parser.js";
24import { tags as t } from "@lezer/highlight";
25import { highlighting } from "./highlight.js"; // highlight.js からインポート
26
27// ショートカット定義
28const myKeymap = [
29  ...defaultKeymap, // 基本的なキー操作(例: Ctrl+Z など)を利用
30  ...foldKeymap,
31  { key: "Enter", run: insertNewlineAndIndent }, // Enterで改行+インデント
32];
33
34// -----------------------------
35// スタイルの定義
36// -----------------------------
37const highlightStyle = HighlightStyle.define([
38  { tag: t.controlKeyword, class: "control-keyword" },
39  { tag: t.function(t.keyword), class: "function-keyword" },
40  { tag: t.keyword, class: "general-keyword" },
41  { tag: t.typeName, class: "type-name" },
42  { tag: t.meta, class: "meta-info" },
43  { tag: t.lineComment, class: "line-comment" },
44  { tag: t.paren, class: "paren" },
45  { tag: t.brace, class: "brace" },
46  { tag: t.squareBracket, class: "square-bracket" },
47]);
48
49// -----------------------------
50// パーサー設定: 折りたたみ可能なノードを指定
51// -----------------------------
52let parserWithMetadata = parser.configure({
53  props: [
54    highlighting,
55    foldNodeProp.add({
56      DefinitionBlock: foldInside,
57      Proof: foldInside,
58      NowBlock: foldInside,
59      HerebyBlock: foldInside,
60    }),
61  ],
62});
63
64// -----------------------------
65// 言語サポートの定義
66// -----------------------------
67const mizarLanguage = LRLanguage.define({
68  parser: parserWithMetadata,
69});
70
71// -----------------------------
72// インデントの定義
73// -----------------------------
74const myIndent = indentNodeProp.add({
75  // "Proof" ノードが来たら親のインデント +1 段
76  Proof(context) {
77    return context.lineIndent(context.node.from) + context.unit;
78  },
79  DefinitionBlock(context) {
80    return context.lineIndent(context.node.from) + context.unit;
81  },
82  NowBlock(context) {
83    return context.lineIndent(context.node.from) + context.unit;
84  },
85  HerebyBlock(context) {
86    return context.lineIndent(context.node.from) + context.unit;
87  }
88});
89
90// インデント込みの拡張 (mizarWithIndent)
91const mizarWithIndent = mizarLanguage.configure({
92  props: [myIndent],
93});
94
95// ★必要であれば、mizarWithIndent を直接使うか、
96// 下記のように mizar() 関数内で呼び出して使ってもOK
97
98// mizar() は インデント拡張なしのバージョン
99export function mizar() {
100  return new LanguageSupport(mizarLanguage);
101}
102
103// -----------------------------
104// ブラケット強調のプラグイン (色分けなど)
105// -----------------------------
106function bracketHighlighter() {
107  return ViewPlugin.fromClass(
108    class {
109      constructor(view) {
110        this.decorations = this.buildDecorations(view);
111      }
112
113      update(update) {
114        if (update.docChanged || update.viewportChanged) {
115          this.decorations = this.buildDecorations(update.view);
116        }
117      }
118
119      buildDecorations(view) {
120        const builder = new RangeSetBuilder();
121        const stack = [];
122        const colors = [
123          "bracket-color-0",
124          "bracket-color-1",
125          "bracket-color-2",
126          "bracket-color-3",
127          "bracket-color-4",
128        ];
129
130        syntaxTree(view.state).iterate({
131          enter: ({ type, from, to }) => {
132            if (
133              type.is("OpenParen") ||
134              type.is("OpenBrace") ||
135              type.is("OpenBracket")
136            ) {
137              stack.push({ type, from, to });
138              const level = stack.length % colors.length;
139              builder.add(from, to, Decoration.mark({ class: colors[level] }));
140            } else if (
141              type.is("CloseParen") ||
142              type.is("CloseBrace") ||
143              type.is("CloseBracket")
144            ) {
145              const open = stack.pop();
146              if (open) {
147                const level = (stack.length + 1) % colors.length;
148                builder.add(from, to, Decoration.mark({ class: colors[level] }));
149              }
150            }
151          },
152        });
153        return builder.finish();
154      }
155    },
156    {
157      decorations: (v) => v.decorations,
158    }
159  );
160}
161
162// -----------------------------
163// Compartment, editors,などのグローバル管理
164// -----------------------------
165const editableCompartment = new Compartment(); // 共有
166const editors = (window.editors = window.editors || {});
167const lineNumberConfigs = (window.lineNumberConfigs = window.lineNumberConfigs || {});
168const editorOrder = (window.editorOrder = window.editorOrder || []);
169
170// エラーデコレーション用
171const errorDecorationEffect = StateEffect.define();
172const errorDecorationsField = StateField.define({
173  create: () => Decoration.none,
174  update(decorations, tr) {
175    decorations = decorations.map(tr.changes);
176    for (let effect of tr.effects) {
177      if (effect.is(errorDecorationEffect)) {
178        decorations = decorations.update({ add: effect.value });
179      }
180    }
181    return decorations;
182  },
183  provide: (f) => EditorView.decorations.from(f),
184});
185
186// -----------------------------
187// Hide/Show 関連
188// -----------------------------
189function toggleMizarEditor(wrapper, hide) {
190  const elementsToToggle = wrapper.querySelectorAll(
191    ".editor-container, .copy-button, .edit-button, .reset-button, .compile-button"
192  );
193  elementsToToggle.forEach((el) => {
194    el.style.display = hide ? "none" : "";
195  });
196
197  const hideButton = wrapper.querySelector(".hide-button");
198  const showButton = wrapper.querySelector(".show-button");
199  if (hideButton && showButton) {
200    hideButton.style.display = hide ? "none" : "inline";
201    showButton.style.display = hide ? "inline" : "none";
202  }
203}
204
205// -----------------------------
206// DOMContentLoaded時の初期化
207// -----------------------------
208document.addEventListener(
209  "DOMContentLoaded",
210  function () {
211    window.mizarInitialized = true;
212
213    const wrappers = document.querySelectorAll(".mizarWrapper");
214    if (wrappers.length === 0) {
215      console.warn("No Mizar blocks found.");
216      return;
217    }
218
219    wrappers.forEach((block) => {
220      const mizarId = block.id.replace("mizarBlock", "");
221      if (!block.querySelector(".editor-container").firstChild) {
222        setupMizarBlock(block, mizarId);
223      }
224    });
225
226    // Hide/Show ボタン
227    const hideButtons = document.querySelectorAll(".hide-button");
228    hideButtons.forEach((button) => {
229      button.addEventListener("click", () => {
230        const parentWrapper = button.closest(".mizarWrapper");
231        if (!parentWrapper) return;
232        toggleMizarEditor(parentWrapper, true);
233      });
234    });
235
236    const showButtons = document.querySelectorAll(".show-button");
237    showButtons.forEach((button) => {
238      button.addEventListener("click", () => {
239        const parentWrapper = button.closest(".mizarWrapper");
240        if (!parentWrapper) return;
241        toggleMizarEditor(parentWrapper, false);
242      });
243    });
244
245    // Hide All, Show All
246    const hideAllButton = document.getElementById("hideAllButton");
247    const showAllButton = document.getElementById("showAllButton");
248    const resetAllButton = document.getElementById("resetAllButton");
249
250    if (hideAllButton && showAllButton) {
251      hideAllButton.addEventListener("click", (e) => {
252        toggleAllWrappers(true);
253        hideAllButton.style.display = "none";
254        showAllButton.style.display = "";
255        e.target.blur();
256      });
257
258      showAllButton.addEventListener("click", (e) => {
259        toggleAllWrappers(false);
260        showAllButton.style.display = "none";
261        hideAllButton.style.display = "";
262        e.target.blur();
263      });
264    }
265
266    function toggleAllWrappers(hide) {
267      const allWrappers = document.querySelectorAll(".mizarWrapper");
268      allWrappers.forEach((wrapper) => {
269        toggleMizarEditor(wrapper, hide);
270      });
271    }
272
273    // Reset All ボタン
274    if (resetAllButton) {
275      resetAllButton.addEventListener("click", (e) => {
276        const allWrappers = document.querySelectorAll(".mizarWrapper");
277        allWrappers.forEach((wrapper) => {
278          const resetBtn = wrapper.querySelector("button[id^='resetButton']");
279          if (resetBtn) {
280            resetBtn.click();
281          }
282        });
283        e.target.blur();
284      });
285    }
286  },
287  { once: true }
288);
289
290// -----------------------------
291// エラー表示パネル
292// -----------------------------
293const toggleErrorPanel = StateEffect.define();
294const errorPanelState = StateField.define({
295  create: () => null,
296  update(value, tr) {
297    for (let e of tr.effects) {
298      if (e.is(toggleErrorPanel)) value = e.value;
299    }
300    return value;
301  },
302  provide: (f) => showPanel.from(f, (val) => (val ? createPanel(val) : null)),
303});
304
305function createPanel(content) {
306  return (_view) => {
307    let dom = document.createElement("div");
308    dom.innerHTML = content;
309    dom.className = "cm-error-panel";
310    return { dom };
311  };
312}
313
314function calculateStartLineNumber(targetMizarId) {
315  let totalLines = 1;
316  for (let mizarId of editorOrder) {
317    if (mizarId === targetMizarId) {
318      break;
319    }
320    totalLines += editors[mizarId].state.doc.lines;
321  }
322  return totalLines;
323}
324
325function adjustLineNumbersForAllEditors() {
326  let totalLines = 1;
327  for (let mizarId of editorOrder) {
328    const editor = editors[mizarId];
329    const startLine = totalLines;
330    const lineNumberExtension = lineNumbers({
331      formatNumber: (number) => `${number + startLine - 1}`,
332    });
333    editor.dispatch({
334      effects: lineNumberConfigs[mizarId].reconfigure(lineNumberExtension),
335    });
336    totalLines += editor.state.doc.lines;
337  }
338}
339
340function getEditorForGlobalLine(globalLine) {
341  let currentLine = 1;
342  for (let mizarId of editorOrder) {
343    const editor = editors[mizarId];
344    const editorLines = editor.state.doc.lines;
345    if (globalLine >= currentLine && globalLine < currentLine + editorLines) {
346      return { editor, localLine: globalLine - currentLine + 1 };
347    }
348    currentLine += editorLines;
349  }
350  return null;
351}
352
353// -----------------------------
354// エディタの初期設定
355// -----------------------------
356function setupMizarBlock(mizarBlock, mizarId) {
357  const editorContainer = mizarBlock.querySelector(".editor-container");
358  editorContainer.id = `editorContainer${mizarId}`;
359  const editorContent = editorContainer.getAttribute("data-content");
360
361  const tempDiv = document.createElement("div");
362  tempDiv.innerHTML = editorContent;
363  const decodedContent = tempDiv.textContent || tempDiv.innerText || "";
364
365  const lineNumberConfig = new Compartment();
366  lineNumberConfigs[mizarId] = lineNumberConfig;
367
368  // codeFolding()を追加して折りたたみを有効化
369  // ※ここで mizarWithIndent を使いたい場合は差し替えます
370  const editor = new EditorView({
371    state: EditorState.create({
372      doc: decodedContent,
373      extensions: [
374        lineNumberConfig.of(
375          lineNumbers({
376            formatNumber: (number) =>
377              `${number + calculateStartLineNumber(mizarId) - 1}`,
378          })
379        ),
380        editableCompartment.of(EditorView.editable.of(false)),
381        syntaxHighlighting(highlightStyle),
382        codeFolding(), // ★ 折りたたみ
383        foldGutter(),
384        keymap.of(myKeymap), // ← キーマップを登録
385        bracketHighlighter(),
386        EditorView.updateListener.of((update) => {
387          if (update.docChanged) {
388            adjustLineNumbersForAllEditors();
389          }
390        }),
391        errorPanelState,
392        errorDecorationsField,
393        mizarWithIndent,
394      ],
395    }),
396    parent: editorContainer,
397  });
398
399  window.editors[mizarId] = editor;
400  editorOrder.push(mizarId);
401
402  // ボタン系
403  const editButton = mizarBlock.querySelector("button[id^='editButton']");
404  const compileButton = mizarBlock.querySelector("button[id^='compileButton']");
405  const resetButton = mizarBlock.querySelector("button[id^='resetButton']");
406
407  editButton.addEventListener("click", () => {
408    editor.dispatch({
409      effects: editableCompartment.reconfigure(EditorView.editable.of(true)),
410    });
411    editButton.style.display = "none";
412    compileButton.style.display = "inline";
413    resetButton.style.display = "inline";
414  });
415
416  compileButton.addEventListener("click", () => {
417      if (mizarBlock.isRequestInProgress) {
418          return;
419      }
420      mizarBlock.isRequestInProgress = true;
421
422      // Spinnerを表示 (この処理を追加)
423      const spinner = document.createElement('div');
424      spinner.className = 'loading-spinner';
425      spinner.innerHTML = 'Loading...';
426      compileButton.parentNode.insertBefore(spinner, compileButton.nextSibling);
427
428      startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId).finally(() => {
429          // Spinnerを削除 (レスポンス後に削除)
430          spinner.remove();
431          mizarBlock.isRequestInProgress = false;
432      });
433  });
434
435  resetButton.addEventListener("click", async () => {
436    const initialContent = editorContainer.getAttribute("data-content");
437    const tempDiv = document.createElement("div");
438    tempDiv.innerHTML = initialContent;
439    const decodedContent = tempDiv.textContent || tempDiv.innerText || "";
440
441    editor.dispatch({
442      changes: { from: 0, to: editor.state.doc.length, insert: decodedContent },
443    });
444
445    editor.dispatch({
446      effects: editableCompartment.reconfigure(EditorView.editable.of(false)),
447    });
448
449    editButton.style.display = "inline";
450    compileButton.style.display = "none";
451    resetButton.style.display = "none";
452
453    editor.dispatch({
454      effects: toggleErrorPanel.of(null),
455    });
456    editor.dispatch({
457      effects: errorDecorationEffect.of([]),
458    });
459
460    try {
461      const response = await fetch(
462        DOKU_BASE + "lib/exe/ajax.php?call=clear_temp_files",
463        {
464          method: "POST",
465          headers: {
466            "Content-Type": "application/x-www-form-urlencoded",
467          },
468        }
469      );
470
471      const text = await response.text();
472      let data;
473      try {
474        data = JSON.parse(text);
475      } catch (error) {
476        throw new Error("Server returned a non-JSON response: " + text);
477      }
478
479      if (data.success) {
480        console.log(data.message);
481      } else {
482        console.error("Failed to clear temporary files:", data.message);
483      }
484    } catch (error) {
485      console.error("Error clearing temporary files:", error);
486    }
487  });
488}
489
490// -----------------------------
491// 行スクロール関連
492// -----------------------------
493function scrollToLine(editor, line) {
494  const lineInfo = editor.state.doc.line(line);
495  editor.dispatch({
496    scrollIntoView: { from: lineInfo.from, to: lineInfo.to },
497  });
498}
499
500// -----------------------------
501// コンパイル開始
502// -----------------------------
503async function startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId) {
504  if (mizarBlock.eventSource) {
505    mizarBlock.eventSource.close();
506  }
507
508  const combinedContent = getCombinedContentUntil(mizarBlock);
509  const data = "content=" + encodeURIComponent(combinedContent);
510
511  try {
512    const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=view_compile", {
513      method: "POST",
514      headers: {
515        "Content-Type": "application/x-www-form-urlencoded",
516      },
517      body: data,
518    });
519    if (!response.ok) {
520      throw new Error("Network response was not ok");
521    }
522
523    mizarBlock.eventSource = new EventSource(
524      DOKU_BASE + "lib/exe/ajax.php?call=view_sse&" + data
525    );
526
527    mizarBlock.eventSource.onmessage = function (event) {
528      const editor = editors[mizarId];
529      let content = editor.state.field(errorPanelState) || "";
530      content += event.data + "<br>";
531      editor.dispatch({
532        effects: toggleErrorPanel.of(content),
533      });
534
535      if (content.includes("❌")) {
536        editor.dom.querySelector(".cm-error-panel").style.backgroundColor = "#fcc";
537      } else {
538        editor.dom.querySelector(".cm-error-panel").style.backgroundColor =
539          "#ccffcc";
540      }
541    };
542
543    mizarBlock.eventSource.addEventListener("compileErrors", function (event) {
544      try {
545        const errors = JSON.parse(event.data);
546        let errorContent = editors[mizarId].state.field(errorPanelState) || "";
547        const decorationsPerEditor = {};
548
549        errors.forEach(function (error) {
550          const { line, column, message } = error;
551          const link = `<a href="#" class="error-link" data-line="${line}" data-column="${column}">[Ln ${line}, Col ${column}]</a>`;
552          errorContent += `❌ ${message} ${link}<br>`;
553
554          const editorInfo = getEditorForGlobalLine(line);
555          if (editorInfo) {
556            const { editor, localLine } = editorInfo;
557            const lineInfo = editor.state.doc.line(localLine);
558            const from = lineInfo.from + (column - 1);
559            const to = from + 1;
560
561            if (from >= lineInfo.from && to <= lineInfo.to) {
562              const deco = Decoration.mark({
563                class: "error-underline",
564                attributes: {
565                  title: `${message} (Ln ${line}, Col ${column})`,
566                },
567              }).range(from, to);
568              const editorId = Object.keys(editors).find(
569                (key) => editors[key] === editor
570              );
571              if (!decorationsPerEditor[editorId]) {
572                decorationsPerEditor[editorId] = [];
573              }
574              decorationsPerEditor[editorId].push(deco);
575            }
576          }
577        });
578
579        for (let editorId in decorationsPerEditor) {
580          const editor = editors[editorId];
581          const decorations = decorationsPerEditor[editorId];
582          if (decorations.length > 0) {
583            editor.dispatch({
584              effects: errorDecorationEffect.of(decorations.sort((a, b) => a.from - b.from)),
585            });
586          }
587        }
588
589        editors[mizarId].dispatch({
590          effects: [toggleErrorPanel.of(errorContent)],
591        });
592
593        if (errorContent.includes("❌")) {
594          editors[mizarId].dom.querySelector(".cm-error-panel").style.backgroundColor =
595            "#fcc";
596        } else {
597          editors[mizarId].dom.querySelector(".cm-error-panel").style.backgroundColor =
598            "#ccffcc";
599        }
600
601        const errorPanel = editors[mizarId].dom.querySelector(".cm-error-panel");
602        errorPanel.querySelectorAll(".error-link").forEach((link) => {
603          link.addEventListener("click", function (e) {
604            e.preventDefault();
605            const line = parseInt(link.getAttribute("data-line"), 10);
606            const editorInfo = getEditorForGlobalLine(line);
607            if (editorInfo) {
608              const { editor, localLine } = editorInfo;
609              scrollToLine(editor, localLine);
610            }
611          });
612        });
613      } catch (e) {
614        console.error("Failed to parse error data:", e);
615        console.error("Event data:", event.data);
616      }
617    });
618
619    mizarBlock.eventSource.onerror = () => {
620      mizarBlock.eventSource.close();
621      mizarBlock.isRequestInProgress = false;
622      finalizeCompilation(mizarBlock);
623    };
624  } catch (error) {
625    console.error("Fetch error: ", error);
626    mizarBlock.isRequestInProgress = false;
627  }
628}
629
630function getCombinedContentUntil(mizarBlock) {
631  let combinedContent = "";
632  const mizarBlocks = document.querySelectorAll(".mizarWrapper");
633  const blockIndex = Array.from(mizarBlocks).indexOf(mizarBlock);
634
635  for (let i = 0; i <= blockIndex; i++) {
636    const mizarId = editorOrder[i];
637    const editor = editors[mizarId];
638    if (editor && editor.state) {
639      const blockContent = editor.state.doc.toString();
640      combinedContent += blockContent + "\n";
641    }
642  }
643  return combinedContent.trim();
644}
645
646function finalizeCompilation(mizarBlock) {
647  const compileButton = mizarBlock.querySelector("[id^='compileButton']");
648  const resetButton = mizarBlock.querySelector("[id^='resetButton']");
649
650  compileButton.style.display = "none";
651  resetButton.style.display = "inline-block";
652  mizarBlock.isRequestInProgress = false;
653}
654
655// -----------------------------
656// ファイル作成関連
657// -----------------------------
658window.createMizarFile = async function (filename) {
659  const combinedContent = collectMizarContents();
660  if (!combinedContent) {
661    console.error("Error: Combined content is empty.");
662    return;
663  }
664  if (!filename.endsWith(".miz")) {
665    filename += ".miz";
666  }
667
668  try {
669    const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=create_combined_file", {
670      method: "POST",
671      headers: {
672        "Content-Type": "application/x-www-form-urlencoded",
673      },
674      body:
675        "content=" +
676        encodeURIComponent(combinedContent) +
677        "&filename=" +
678        encodeURIComponent(filename),
679    });
680
681    if (!response.ok) {
682      console.error("Failed to create file: Network response was not ok");
683      return;
684    }
685
686    const data = await response.json();
687    if (data.success) {
688      console.log("File created successfully:", filename);
689
690      const blob = new Blob([data.data.content], { type: "application/octet-stream" });
691      const url = URL.createObjectURL(blob);
692
693      const contentWindow = window.open("", "_blank");
694      contentWindow.document.write(
695        "<pre>" +
696          data.data.content
697            .replace(/&/g, "&amp;")
698            .replace(/</g, "&lt;")
699            .replace(/>/g, "&gt;") +
700          "</pre>"
701      );
702      contentWindow.document.title = filename;
703
704      const downloadLink = contentWindow.document.createElement("a");
705      downloadLink.href = url;
706      downloadLink.download = filename;
707      downloadLink.textContent = "⬇️ Click here to download the file";
708      downloadLink.style.display = "block";
709      downloadLink.style.marginTop = "10px";
710      contentWindow.document.body.appendChild(downloadLink);
711
712      contentWindow.addEventListener("unload", () => {
713        URL.revokeObjectURL(url);
714      });
715    } else {
716      console.error("Failed to create file:", data.message);
717    }
718  } catch (error) {
719    console.error("Error creating file:", error);
720  }
721};
722
723function collectMizarContents() {
724  let combinedContent = "";
725  for (let mizarId of editorOrder) {
726    const editor = editors[mizarId];
727    if (editor && editor.state) {
728      combinedContent += editor.state.doc.toString() + "\n";
729    }
730  }
731  return combinedContent;
732}
733
734// -----------------------------
735// Copyボタンの実装
736// -----------------------------
737document.addEventListener("DOMContentLoaded", () => {
738  const copyButtons = document.querySelectorAll(".copy-button");
739  copyButtons.forEach((button) => {
740    button.addEventListener("click", (event) => {
741      const buttonElement = event.currentTarget;
742      const mizarIdRaw = buttonElement.dataset.mizarid;
743      const mizarId = mizarIdRaw.replace("mizarBlock", "");
744
745      if (!editors[mizarId]) {
746        console.error("エディタが見つかりません: ", mizarIdRaw);
747        return;
748      }
749
750      const editor = editors[mizarId];
751      const content = editor.state.doc.toString();
752
753      navigator.clipboard
754        .writeText(content)
755        .then(() => {
756          buttonElement.textContent = "Copied!";
757          buttonElement.disabled = true;
758
759          setTimeout(() => {
760            buttonElement.textContent = "Copy";
761            buttonElement.disabled = false;
762          }, 2000);
763        })
764        .catch((err) => {
765          console.error("コピーに失敗しました: ", err);
766        });
767    });
768  });
769});
770