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