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