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