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