xref: /plugin/mizarverifiabledocs/src/script.js (revision 1197729184122a30475a0859b897e87e87d1d70d)
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    startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId);
422  });
423
424  resetButton.addEventListener("click", async () => {
425    const initialContent = editorContainer.getAttribute("data-content");
426    const tempDiv = document.createElement("div");
427    tempDiv.innerHTML = initialContent;
428    const decodedContent = tempDiv.textContent || tempDiv.innerText || "";
429
430    editor.dispatch({
431      changes: { from: 0, to: editor.state.doc.length, insert: decodedContent },
432    });
433
434    editor.dispatch({
435      effects: editableCompartment.reconfigure(EditorView.editable.of(false)),
436    });
437
438    editButton.style.display = "inline";
439    compileButton.style.display = "none";
440    resetButton.style.display = "none";
441
442    editor.dispatch({
443      effects: toggleErrorPanel.of(null),
444    });
445    editor.dispatch({
446      effects: errorDecorationEffect.of([]),
447    });
448
449    try {
450      const response = await fetch(
451        DOKU_BASE + "lib/exe/ajax.php?call=clear_temp_files",
452        {
453          method: "POST",
454          headers: {
455            "Content-Type": "application/x-www-form-urlencoded",
456          },
457        }
458      );
459
460      const text = await response.text();
461      let data;
462      try {
463        data = JSON.parse(text);
464      } catch (error) {
465        throw new Error("Server returned a non-JSON response: " + text);
466      }
467
468      if (data.success) {
469        console.log(data.message);
470      } else {
471        console.error("Failed to clear temporary files:", data.message);
472      }
473    } catch (error) {
474      console.error("Error clearing temporary files:", error);
475    }
476  });
477}
478
479// -----------------------------
480// 行スクロール関連
481// -----------------------------
482function scrollToLine(editor, line) {
483  const lineInfo = editor.state.doc.line(line);
484  editor.dispatch({
485    scrollIntoView: { from: lineInfo.from, to: lineInfo.to },
486  });
487}
488
489// -----------------------------
490// コンパイル開始
491// -----------------------------
492async function startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId) {
493  if (mizarBlock.eventSource) {
494    mizarBlock.eventSource.close();
495  }
496
497  const combinedContent = getCombinedContentUntil(mizarBlock);
498  const data = "content=" + encodeURIComponent(combinedContent);
499
500  try {
501    const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=view_compile", {
502      method: "POST",
503      headers: {
504        "Content-Type": "application/x-www-form-urlencoded",
505      },
506      body: data,
507    });
508    if (!response.ok) {
509      throw new Error("Network response was not ok");
510    }
511
512    mizarBlock.eventSource = new EventSource(
513      DOKU_BASE + "lib/exe/ajax.php?call=view_sse&" + data
514    );
515
516    mizarBlock.eventSource.onmessage = function (event) {
517      const editor = editors[mizarId];
518      let content = editor.state.field(errorPanelState) || "";
519      content += event.data + "<br>";
520      editor.dispatch({
521        effects: toggleErrorPanel.of(content),
522      });
523
524      if (content.includes("❌")) {
525        editor.dom.querySelector(".cm-error-panel").style.backgroundColor = "#fcc";
526      } else {
527        editor.dom.querySelector(".cm-error-panel").style.backgroundColor =
528          "#ccffcc";
529      }
530    };
531
532    mizarBlock.eventSource.addEventListener("compileErrors", function (event) {
533      try {
534        const errors = JSON.parse(event.data);
535        let errorContent = editors[mizarId].state.field(errorPanelState) || "";
536        const decorationsPerEditor = {};
537
538        errors.forEach(function (error) {
539          const { line, column, message } = error;
540          const link = `<a href="#" class="error-link" data-line="${line}" data-column="${column}">[Ln ${line}, Col ${column}]</a>`;
541          errorContent += `❌ ${message} ${link}<br>`;
542
543          const editorInfo = getEditorForGlobalLine(line);
544          if (editorInfo) {
545            const { editor, localLine } = editorInfo;
546            const lineInfo = editor.state.doc.line(localLine);
547            const from = lineInfo.from + (column - 1);
548            const to = from + 1;
549
550            if (from >= lineInfo.from && to <= lineInfo.to) {
551              const deco = Decoration.mark({
552                class: "error-underline",
553                attributes: {
554                  title: `${message} (Ln ${line}, Col ${column})`,
555                },
556              }).range(from, to);
557              const editorId = Object.keys(editors).find(
558                (key) => editors[key] === editor
559              );
560              if (!decorationsPerEditor[editorId]) {
561                decorationsPerEditor[editorId] = [];
562              }
563              decorationsPerEditor[editorId].push(deco);
564            }
565          }
566        });
567
568        for (let editorId in decorationsPerEditor) {
569          const editor = editors[editorId];
570          const decorations = decorationsPerEditor[editorId];
571          if (decorations.length > 0) {
572            editor.dispatch({
573              effects: errorDecorationEffect.of(decorations.sort((a, b) => a.from - b.from)),
574            });
575          }
576        }
577
578        editors[mizarId].dispatch({
579          effects: [toggleErrorPanel.of(errorContent)],
580        });
581
582        if (errorContent.includes("❌")) {
583          editors[mizarId].dom.querySelector(".cm-error-panel").style.backgroundColor =
584            "#fcc";
585        } else {
586          editors[mizarId].dom.querySelector(".cm-error-panel").style.backgroundColor =
587            "#ccffcc";
588        }
589
590        const errorPanel = editors[mizarId].dom.querySelector(".cm-error-panel");
591        errorPanel.querySelectorAll(".error-link").forEach((link) => {
592          link.addEventListener("click", function (e) {
593            e.preventDefault();
594            const line = parseInt(link.getAttribute("data-line"), 10);
595            const editorInfo = getEditorForGlobalLine(line);
596            if (editorInfo) {
597              const { editor, localLine } = editorInfo;
598              scrollToLine(editor, localLine);
599            }
600          });
601        });
602      } catch (e) {
603        console.error("Failed to parse error data:", e);
604        console.error("Event data:", event.data);
605      }
606    });
607
608    mizarBlock.eventSource.onerror = () => {
609      mizarBlock.eventSource.close();
610      mizarBlock.isRequestInProgress = false;
611      finalizeCompilation(mizarBlock);
612    };
613  } catch (error) {
614    console.error("Fetch error: ", error);
615    mizarBlock.isRequestInProgress = false;
616  }
617}
618
619function getCombinedContentUntil(mizarBlock) {
620  let combinedContent = "";
621  const mizarBlocks = document.querySelectorAll(".mizarWrapper");
622  const blockIndex = Array.from(mizarBlocks).indexOf(mizarBlock);
623
624  for (let i = 0; i <= blockIndex; i++) {
625    const mizarId = editorOrder[i];
626    const editor = editors[mizarId];
627    if (editor && editor.state) {
628      const blockContent = editor.state.doc.toString();
629      combinedContent += blockContent + "\n";
630    }
631  }
632  return combinedContent.trim();
633}
634
635function finalizeCompilation(mizarBlock) {
636  const compileButton = mizarBlock.querySelector("[id^='compileButton']");
637  const resetButton = mizarBlock.querySelector("[id^='resetButton']");
638
639  compileButton.style.display = "none";
640  resetButton.style.display = "inline-block";
641  mizarBlock.isRequestInProgress = false;
642}
643
644// -----------------------------
645// ファイル作成関連
646// -----------------------------
647window.createMizarFile = async function (filename) {
648  const combinedContent = collectMizarContents();
649  if (!combinedContent) {
650    console.error("Error: Combined content is empty.");
651    return;
652  }
653  if (!filename.endsWith(".miz")) {
654    filename += ".miz";
655  }
656
657  try {
658    const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=create_combined_file", {
659      method: "POST",
660      headers: {
661        "Content-Type": "application/x-www-form-urlencoded",
662      },
663      body:
664        "content=" +
665        encodeURIComponent(combinedContent) +
666        "&filename=" +
667        encodeURIComponent(filename),
668    });
669
670    if (!response.ok) {
671      console.error("Failed to create file: Network response was not ok");
672      return;
673    }
674
675    const data = await response.json();
676    if (data.success) {
677      console.log("File created successfully:", filename);
678
679      const blob = new Blob([data.data.content], { type: "application/octet-stream" });
680      const url = URL.createObjectURL(blob);
681
682      const contentWindow = window.open("", "_blank");
683      contentWindow.document.write(
684        "<pre>" +
685          data.data.content
686            .replace(/&/g, "&amp;")
687            .replace(/</g, "&lt;")
688            .replace(/>/g, "&gt;") +
689          "</pre>"
690      );
691      contentWindow.document.title = filename;
692
693      const downloadLink = contentWindow.document.createElement("a");
694      downloadLink.href = url;
695      downloadLink.download = filename;
696      downloadLink.textContent = "⬇️ Click here to download the file";
697      downloadLink.style.display = "block";
698      downloadLink.style.marginTop = "10px";
699      contentWindow.document.body.appendChild(downloadLink);
700
701      contentWindow.addEventListener("unload", () => {
702        URL.revokeObjectURL(url);
703      });
704    } else {
705      console.error("Failed to create file:", data.message);
706    }
707  } catch (error) {
708    console.error("Error creating file:", error);
709  }
710};
711
712function collectMizarContents() {
713  let combinedContent = "";
714  for (let mizarId of editorOrder) {
715    const editor = editors[mizarId];
716    if (editor && editor.state) {
717      combinedContent += editor.state.doc.toString() + "\n";
718    }
719  }
720  return combinedContent;
721}
722
723// -----------------------------
724// Copyボタンの実装
725// -----------------------------
726document.addEventListener("DOMContentLoaded", () => {
727  const copyButtons = document.querySelectorAll(".copy-button");
728  copyButtons.forEach((button) => {
729    button.addEventListener("click", (event) => {
730      const buttonElement = event.currentTarget;
731      const mizarIdRaw = buttonElement.dataset.mizarid;
732      const mizarId = mizarIdRaw.replace("mizarBlock", "");
733
734      if (!editors[mizarId]) {
735        console.error("エディタが見つかりません: ", mizarIdRaw);
736        return;
737      }
738
739      const editor = editors[mizarId];
740      const content = editor.state.doc.toString();
741
742      navigator.clipboard
743        .writeText(content)
744        .then(() => {
745          buttonElement.textContent = "Copied!";
746          buttonElement.disabled = true;
747
748          setTimeout(() => {
749            buttonElement.textContent = "Copy";
750            buttonElement.disabled = false;
751          }, 2000);
752        })
753        .catch((err) => {
754          console.error("コピーに失敗しました: ", err);
755        });
756    });
757  });
758});
759