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 166 if (hideAllButton && showAllButton) { 167 hideAllButton.addEventListener('click', (e) => { 168 // Hide All 処理 169 toggleAllWrappers(true); // 全て hide 170 hideAllButton.style.display = 'none'; 171 showAllButton.style.display = ''; 172 e.target.blur(); // フォーカスを外す 173 }); 174 175 showAllButton.addEventListener('click', (e) => { 176 // Show All 処理 177 toggleAllWrappers(false); // 全て show 178 showAllButton.style.display = 'none'; 179 hideAllButton.style.display = ''; 180 e.target.blur(); // フォーカスを外す 181 }); 182 } 183 184 function toggleAllWrappers(hide) { 185 const allWrappers = document.querySelectorAll('.mizarWrapper'); 186 allWrappers.forEach((wrapper) => { 187 toggleMizarEditor(wrapper, hide); 188 }); 189 } 190}, { once: true }); 191 192// パネルの表示を制御するエフェクトとステートフィールド 193const toggleErrorPanel = StateEffect.define(); 194const errorPanelState = StateField.define({ 195 create: () => null, 196 update(value, tr) { 197 for (let e of tr.effects) { 198 if (e.is(toggleErrorPanel)) value = e.value; 199 } 200 return value; 201 }, 202 provide: f => showPanel.from(f, val => val ? createPanel(val) : null) 203}); 204 205function createPanel(content) { 206 return (_view) => { 207 let dom = document.createElement("div"); 208 dom.innerHTML = content; 209 dom.className = "cm-error-panel"; 210 return { dom }; 211 }; 212} 213 214function calculateStartLineNumber(targetMizarId) { 215 let totalLines = 1; 216 for (let mizarId of editorOrder) { 217 if (mizarId === targetMizarId) { 218 break; 219 } 220 totalLines += editors[mizarId].state.doc.lines; 221 } 222 return totalLines; 223} 224 225function adjustLineNumbersForAllEditors() { 226 let totalLines = 1; 227 for (let mizarId of editorOrder) { 228 const editor = editors[mizarId]; 229 const startLine = totalLines; 230 const lineNumberExtension = lineNumbers({ 231 formatNumber: number => `${number + startLine - 1}` 232 }); 233 editor.dispatch({ 234 effects: lineNumberConfigs[mizarId].reconfigure(lineNumberExtension) 235 }); 236 totalLines += editor.state.doc.lines; 237 } 238} 239 240function getEditorForGlobalLine(globalLine) { 241 let currentLine = 1; 242 for (let mizarId of editorOrder) { 243 const editor = editors[mizarId]; 244 const editorLines = editor.state.doc.lines; 245 if (globalLine >= currentLine && globalLine < currentLine + editorLines) { 246 return { editor, localLine: globalLine - currentLine + 1 }; 247 } 248 currentLine += editorLines; 249 } 250 return null; 251} 252 253// エディタの初期設定時にリスナーを追加 254function setupMizarBlock(mizarBlock, mizarId) { 255 const editorContainer = mizarBlock.querySelector('.editor-container'); 256 editorContainer.id = `editorContainer${mizarId}`; 257 const editorContent = editorContainer.getAttribute('data-content'); 258 259 const tempDiv = document.createElement('div'); 260 tempDiv.innerHTML = editorContent; 261 const decodedContent = tempDiv.textContent || tempDiv.innerText || ""; 262 263 const lineNumberConfig = new Compartment(); 264 lineNumberConfigs[mizarId] = lineNumberConfig; 265 266 // codeFolding()を追加して折りたたみを有効化 267 const editor = new EditorView({ 268 state: EditorState.create({ 269 doc: decodedContent, 270 extensions: [ 271 lineNumberConfig.of(lineNumbers({ formatNumber: number => `${number + calculateStartLineNumber(mizarId) - 1}` })), 272 editableCompartment.of(EditorView.editable.of(false)), 273 syntaxHighlighting(highlightStyle), 274 codeFolding(), // ★ 追加 275 foldGutter(), // ★ 折りたたみガター 276 keymap.of(foldKeymap), // ★ 折りたたみ用キーマップ 277 bracketHighlighter(), 278 EditorView.updateListener.of(update => { 279 if (update.docChanged) { 280 adjustLineNumbersForAllEditors(); 281 } 282 }), 283 errorPanelState, 284 errorDecorationsField, 285 mizar() 286 ] 287 }), 288 parent: editorContainer 289 }); 290 window.editors[mizarId] = editor; 291 editorOrder.push(mizarId); 292 293 const editButton = mizarBlock.querySelector('button[id^="editButton"]'); 294 const compileButton = mizarBlock.querySelector('button[id^="compileButton"]'); 295 const resetButton = mizarBlock.querySelector('button[id^="resetButton"]'); 296 297 editButton.addEventListener('click', () => { 298 editor.dispatch({ 299 effects: editableCompartment.reconfigure(EditorView.editable.of(true)) 300 }); 301 editButton.style.display = 'none'; 302 compileButton.style.display = 'inline'; 303 resetButton.style.display = 'inline'; 304 }); 305 306 compileButton.addEventListener('click', () => { 307 if (mizarBlock.isRequestInProgress) { 308 return; 309 } 310 mizarBlock.isRequestInProgress = true; 311 startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId); 312 }); 313 314 resetButton.addEventListener('click', async () => { 315 const initialContent = editorContainer.getAttribute('data-content'); 316 const tempDiv = document.createElement('div'); 317 tempDiv.innerHTML = initialContent; 318 const decodedContent = tempDiv.textContent || tempDiv.innerText || ""; 319 320 editor.dispatch({ 321 changes: { from: 0, to: editor.state.doc.length, insert: decodedContent } 322 }); 323 324 editor.dispatch({ 325 effects: editableCompartment.reconfigure(EditorView.editable.of(false)) 326 }); 327 328 editButton.style.display = 'inline'; 329 compileButton.style.display = 'none'; 330 resetButton.style.display = 'none'; 331 332 editor.dispatch({ 333 effects: toggleErrorPanel.of(null) 334 }); 335 336 editor.dispatch({ 337 effects: errorDecorationEffect.of([]) 338 }); 339 340 try { 341 const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=clear_temp_files", { 342 method: "POST", 343 headers: { 344 "Content-Type": "application/x-www-form-urlencoded" 345 } 346 }); 347 348 const text = await response.text(); 349 let data; 350 try { 351 data = JSON.parse(text); 352 } catch (error) { 353 throw new Error("Server returned a non-JSON response: " + text); 354 } 355 356 if (data.success) { 357 console.log(data.message); 358 } else { 359 console.error("Failed to clear temporary files:", data.message); 360 } 361 } catch (error) { 362 console.error("Error clearing temporary files:", error); 363 } 364 }); 365 366 // Enterキーで行追加 367 editor.dom.addEventListener('keydown', (e) => { 368 if (e.key === 'Enter' && editor.state.facet(EditorView.editable)) { 369 e.preventDefault(); 370 const transaction = editor.state.update({ 371 changes: { from: editor.state.selection.main.head, insert: '\n' } 372 }); 373 editor.dispatch(transaction); 374 } 375 }); 376} 377 378function scrollToLine(editor, line) { 379 const lineInfo = editor.state.doc.line(line); 380 editor.dispatch({ 381 scrollIntoView: { from: lineInfo.from, to: lineInfo.to } 382 }); 383} 384 385async function startMizarCompilation(mizarBlock, toggleErrorPanel, mizarId) { 386 if (mizarBlock.eventSource) { 387 mizarBlock.eventSource.close(); 388 } 389 390 const combinedContent = getCombinedContentUntil(mizarBlock); 391 const data = "content=" + encodeURIComponent(combinedContent); 392 393 try { 394 const response = await fetch(DOKU_BASE + "lib/exe/ajax.php?call=view_compile", { 395 method: "POST", 396 headers: { 397 "Content-Type": "application/x-www-form-urlencoded" 398 }, 399 body: data 400 }); 401 402 if (!response.ok) { 403 throw new Error("Network response was not ok"); 404 } 405 406 mizarBlock.eventSource = new EventSource(DOKU_BASE + "lib/exe/ajax.php?call=view_sse&" + data); 407 408 mizarBlock.eventSource.onmessage = function(event) { 409 const editor = editors[mizarId]; 410 let content = editor.state.field(errorPanelState) || ''; 411 content += event.data + '<br>'; 412 editor.dispatch({ 413 effects: toggleErrorPanel.of(content) 414 }); 415 416 if (content.includes('❌')) { 417 editor.dom.querySelector('.cm-error-panel').style.backgroundColor = '#fcc'; 418 } else { 419 editor.dom.querySelector('.cm-error-panel').style.backgroundColor = '#ccffcc'; 420 } 421 }; 422 423 mizarBlock.eventSource.addEventListener('compileErrors', function(event) { 424 try { 425 const errors = JSON.parse(event.data); 426 let errorContent = editors[mizarId].state.field(errorPanelState) || ''; 427 const decorationsPerEditor = {}; 428 429 errors.forEach(function(error) { 430 const { line, column, message } = error; 431 const link = `<a href="#" class="error-link" data-line="${line}" data-column="${column}">[Ln ${line}, Col ${column}]</a>`; 432 errorContent += `❌ ${message} ${link}<br>`; 433 434 const editorInfo = getEditorForGlobalLine(line); 435 if (editorInfo) { 436 const { editor, localLine } = editorInfo; 437 const lineInfo = editor.state.doc.line(localLine); 438 const from = lineInfo.from + (column - 1); 439 const to = from + 1; 440 441 if (from >= lineInfo.from && to <= lineInfo.to) { 442 const deco = Decoration.mark({ 443 class: "error-underline", 444 attributes: { title: `${message} (Ln ${line}, Col ${column})` } 445 }).range(from, to); 446 const editorId = Object.keys(editors).find(key => editors[key] === editor); 447 if (!decorationsPerEditor[editorId]) { 448 decorationsPerEditor[editorId] = []; 449 } 450 decorationsPerEditor[editorId].push(deco); 451 } 452 } 453 }); 454 455 for (let editorId in decorationsPerEditor) { 456 const editor = editors[editorId]; 457 const decorations = decorationsPerEditor[editorId]; 458 if (decorations.length > 0) { 459 editor.dispatch({ 460 effects: errorDecorationEffect.of(decorations.sort((a, b) => a.from - b.from)) 461 }); 462 } 463 } 464 465 editors[mizarId].dispatch({ 466 effects: [ 467 toggleErrorPanel.of(errorContent) 468 ] 469 }); 470 471 if (errorContent.includes('❌')) { 472 editors[mizarId].dom.querySelector('.cm-error-panel').style.backgroundColor = '#fcc'; 473 } else { 474 editors[mizarId].dom.querySelector('.cm-error-panel').style.backgroundColor = '#ccffcc'; 475 } 476 477 const errorPanel = editors[mizarId].dom.querySelector('.cm-error-panel'); 478 errorPanel.querySelectorAll('.error-link').forEach(link => { 479 link.addEventListener('click', function(e) { 480 e.preventDefault(); 481 const line = parseInt(link.getAttribute('data-line'), 10); 482 const editorInfo = getEditorForGlobalLine(line); 483 if (editorInfo) { 484 const { editor, localLine } = editorInfo; 485 scrollToLine(editor, localLine); 486 } 487 }); 488 }); 489 } catch (e) { 490 console.error('Failed to parse error data:', e); 491 console.error('Event data:', event.data); 492 } 493 }); 494 495 mizarBlock.eventSource.onerror = () => { 496 mizarBlock.eventSource.close(); 497 mizarBlock.isRequestInProgress = false; 498 finalizeCompilation(mizarBlock); 499 }; 500 } catch (error) { 501 console.error("Fetch error: ", error); 502 mizarBlock.isRequestInProgress = false; 503 } 504} 505 506function getCombinedContentUntil(mizarBlock) { 507 let combinedContent = ''; 508 const mizarBlocks = document.querySelectorAll('.mizarWrapper'); 509 const blockIndex = Array.from(mizarBlocks).indexOf(mizarBlock); 510 511 for (let i = 0; i <= blockIndex; i++) { 512 const mizarId = editorOrder[i]; 513 const editor = editors[mizarId]; 514 if (editor && editor.state) { 515 const blockContent = editor.state.doc.toString(); 516 combinedContent += blockContent + "\n"; 517 } 518 } 519 520 return combinedContent.trim(); 521} 522 523function finalizeCompilation(mizarBlock) { 524 const compileButton = mizarBlock.querySelector('[id^="compileButton"]'); 525 const resetButton = mizarBlock.querySelector('[id^="resetButton"]'); 526 527 compileButton.style.display = 'none'; 528 resetButton.style.display = 'inline-block'; 529 530 mizarBlock.isRequestInProgress = false; 531} 532 533window.createMizarFile = async function(filename) { 534 const combinedContent = collectMizarContents(); 535 536 if (!combinedContent) { 537 console.error('Error: Combined content is empty.'); 538 return; 539 } 540 541 if (!filename.endsWith('.miz')) { 542 filename += '.miz'; 543 } 544 545 try { 546 const response = await fetch(DOKU_BASE + 'lib/exe/ajax.php?call=create_combined_file', { 547 method: 'POST', 548 headers: { 549 'Content-Type': 'application/x-www-form-urlencoded' 550 }, 551 body: 'content=' + encodeURIComponent(combinedContent) + '&filename=' + encodeURIComponent(filename) 552 }); 553 554 if (!response.ok) { 555 console.error('Failed to create file: Network response was not ok'); 556 return; 557 } 558 559 const data = await response.json(); 560 if (data.success) { 561 console.log('File created successfully:', filename); 562 563 const blob = new Blob([data.data.content], { type: 'application/octet-stream' }); 564 const url = URL.createObjectURL(blob); 565 566 const contentWindow = window.open('', '_blank'); 567 contentWindow.document.write('<pre>' + data.data.content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + '</pre>'); 568 contentWindow.document.title = filename; 569 570 const downloadLink = contentWindow.document.createElement('a'); 571 downloadLink.href = url; 572 downloadLink.download = filename; 573 downloadLink.textContent = '⬇️ Click here to download the file'; 574 downloadLink.style.display = 'block'; 575 downloadLink.style.marginTop = '10px'; 576 contentWindow.document.body.appendChild(downloadLink); 577 578 contentWindow.addEventListener('unload', () => { 579 URL.revokeObjectURL(url); 580 }); 581 } else { 582 console.error('Failed to create file:', data.message); 583 } 584 } catch (error) { 585 console.error('Error creating file:', error); 586 } 587}; 588 589function collectMizarContents() { 590 let combinedContent = ''; 591 for (let mizarId of editorOrder) { 592 const editor = editors[mizarId]; 593 if (editor && editor.state) { 594 combinedContent += editor.state.doc.toString() + '\n'; 595 } 596 } 597 return combinedContent; 598} 599 600document.addEventListener("DOMContentLoaded", () => { 601 const copyButtons = document.querySelectorAll('.copy-button'); 602 603 copyButtons.forEach((button) => { 604 button.addEventListener('click', (event) => { 605 const buttonElement = event.currentTarget; 606 const mizarIdRaw = buttonElement.dataset.mizarid; 607 const mizarId = mizarIdRaw.replace('mizarBlock', ''); 608 609 if (!editors[mizarId]) { 610 console.error('エディタが見つかりません: ', mizarIdRaw); 611 return; 612 } 613 614 const editor = editors[mizarId]; 615 const content = editor.state.doc.toString(); 616 617 navigator.clipboard.writeText(content).then(() => { 618 buttonElement.textContent = 'Copied!'; 619 buttonElement.disabled = true; 620 621 setTimeout(() => { 622 buttonElement.textContent = 'Copy'; 623 buttonElement.disabled = false; 624 }, 2000); 625 }).catch((err) => { 626 console.error('コピーに失敗しました: ', err); 627 }); 628 }); 629 }); 630}); 631