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