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 try { 498 const response = await fetch(DOKU_BASE + 'lib/exe/ajax.php?call=create_combined_file', { 499 method: 'POST', 500 headers: { 501 'Content-Type': 'application/x-www-form-urlencoded' 502 }, 503 body: 'content=' + encodeURIComponent(combinedContent) + '&filename=' + encodeURIComponent(filename) 504 }); 505 506 if (!response.ok) { 507 console.error('Failed to create file: Network response was not ok'); 508 return; 509 } 510 511 const data = await response.json(); 512 if (data.success) { 513 console.log('File created successfully:', filename); 514 515 // ファイルの内容を表示する新しいタブを開く 516 const contentWindow = window.open('', '_blank'); 517 contentWindow.document.write(`<pre>${data.data.content}</pre>`); 518 contentWindow.document.title = filename; 519 520 // Blobを使ってダウンロードリンクを作成 521 const blob = new Blob([data.data.content], { type: 'text/plain' }); 522 const url = URL.createObjectURL(blob); 523 524 // ダウンロードリンクの要素を作成して表示 525 const downloadLink = contentWindow.document.createElement('a'); 526 downloadLink.href = url; 527 downloadLink.download = filename; 528 downloadLink.textContent = '⬇️ Click here to download the file'; 529 downloadLink.style.display = 'block'; 530 downloadLink.style.marginTop = '10px'; 531 // タブ内にダウンロードリンクを追加 532 contentWindow.document.body.appendChild(downloadLink); 533 534 // リソースの解放 535 contentWindow.addEventListener('unload', () => { 536 URL.revokeObjectURL(url); 537 }); 538 } else { 539 console.error('Failed to create file:', data.message); 540 } 541 } catch (error) { 542 console.error('Error creating file:', error); 543 } 544}; 545 546function collectMizarContents() { 547 let combinedContent = ''; 548 for (let mizarId of editorOrder) { 549 const editor = editors[mizarId]; 550 if (editor && editor.state) { 551 combinedContent += editor.state.doc.toString() + '\n'; 552 } 553 } 554 return combinedContent; 555} 556 557document.addEventListener("DOMContentLoaded", () => { 558 const copyButtons = document.querySelectorAll('.copy-button'); 559 560 copyButtons.forEach((button) => { 561 button.addEventListener('click', (event) => { 562 const buttonElement = event.currentTarget; 563 const mizarIdRaw = buttonElement.dataset.mizarid; // mizarBlock12 形式を取得 564 const mizarId = mizarIdRaw.replace('mizarBlock', ''); // 数字部分を抽出 565 566 // エディタが存在するか確認 567 if (!editors[mizarId]) { 568 console.error('エディタが見つかりません: ', mizarIdRaw); 569 return; 570 } 571 572 const editor = editors[mizarId]; 573 const content = editor.state.doc.toString(); 574 575 // クリップボードにコピー 576 navigator.clipboard.writeText(content).then(() => { 577 buttonElement.textContent = 'Copied!'; 578 buttonElement.disabled = true; 579 580 setTimeout(() => { 581 buttonElement.textContent = 'Copy'; 582 buttonElement.disabled = false; 583 }, 2000); 584 }).catch((err) => { 585 console.error('コピーに失敗しました: ', err); 586 }); 587 }); 588 }); 589});