|
540 | 540 | } |
541 | 541 | pageHeader.appendChild(metaBar); |
542 | 542 |
|
| 543 | + // Edit button (toggles inline editor) |
| 544 | + var editBtn = el('button', 'wiki-edit-btn'); |
| 545 | + editBtn.type = 'button'; |
| 546 | + editBtn.textContent = 'Edit'; |
| 547 | + editBtn.addEventListener('click', function() { |
| 548 | + openEditor(main, data, pmeta); |
| 549 | + }); |
| 550 | + pageHeader.appendChild(editBtn); |
| 551 | + |
543 | 552 | article.appendChild(pageHeader); |
544 | 553 |
|
545 | 554 | // Body |
546 | 555 | var bodyEl = el('div', 'wiki-body'); |
547 | 556 | bodyEl.innerHTML = renderMarkdown(body); |
548 | 557 |
|
| 558 | + // KaTeX math — renders $…$ and $$…$$ spans to real math. |
| 559 | + if (window.renderMathInElement) { |
| 560 | + try { |
| 561 | + window.renderMathInElement(bodyEl, { |
| 562 | + delimiters: [ |
| 563 | + { left: '$$', right: '$$', display: true }, |
| 564 | + { left: '$', right: '$', display: false }, |
| 565 | + { left: '\\(', right: '\\)', display: false }, |
| 566 | + { left: '\\[', right: '\\]', display: true } |
| 567 | + ], |
| 568 | + throwOnError: false |
| 569 | + }); |
| 570 | + } catch (e) { /* KaTeX optional; swallow failures */ } |
| 571 | + } |
| 572 | + |
549 | 573 | // Wire internal wiki links |
550 | 574 | bodyEl.querySelectorAll('.wiki-link').forEach(function(link) { |
551 | 575 | link.addEventListener('click', function() { |
|
953 | 977 | return e; |
954 | 978 | } |
955 | 979 |
|
| 980 | + // ── Inline editor (Phase 8.3) ── |
| 981 | + // |
| 982 | + // Lazy-loads CodeMirror 6 from esm.sh the first time the user clicks |
| 983 | + // Edit. Keeps the initial wiki page load light (~200KB CM6 bundle |
| 984 | + // isn't paid until needed). Split-pane: left = source, right = live |
| 985 | + // markdown preview via the existing renderMarkdown + KaTeX. |
| 986 | + |
| 987 | + var _cmModulesPromise = null; |
| 988 | + function _loadCodeMirror() { |
| 989 | + if (_cmModulesPromise) return _cmModulesPromise; |
| 990 | + _cmModulesPromise = (async function() { |
| 991 | + // Core + markdown mode + theme — via esm.sh (zero build, cached) |
| 992 | + var urls = { |
| 993 | + view: 'https://esm.sh/@codemirror/view@6', |
| 994 | + state: 'https://esm.sh/@codemirror/state@6', |
| 995 | + commands: 'https://esm.sh/@codemirror/commands@6', |
| 996 | + lang: 'https://esm.sh/@codemirror/lang-markdown@6', |
| 997 | + oneDark: 'https://esm.sh/@codemirror/theme-one-dark@6', |
| 998 | + autoClose: 'https://esm.sh/@codemirror/autocomplete@6' |
| 999 | + }; |
| 1000 | + var mods = {}; |
| 1001 | + await Promise.all(Object.keys(urls).map(async function(k) { |
| 1002 | + mods[k] = await import(urls[k]); |
| 1003 | + })); |
| 1004 | + return mods; |
| 1005 | + })(); |
| 1006 | + return _cmModulesPromise; |
| 1007 | + } |
| 1008 | + |
| 1009 | + async function openEditor(main, data, pmeta) { |
| 1010 | + var original = main.innerHTML; |
| 1011 | + main.innerHTML = '<div class="wiki-loading"><div class="wiki-loading-spinner"></div>Loading editor\u2026</div>'; |
| 1012 | + |
| 1013 | + var mods; |
| 1014 | + try { |
| 1015 | + mods = await _loadCodeMirror(); |
| 1016 | + } catch (err) { |
| 1017 | + console.warn('[cortex] CodeMirror load failed', err); |
| 1018 | + main.innerHTML = original; |
| 1019 | + alert('Editor failed to load. See console for details.'); |
| 1020 | + return; |
| 1021 | + } |
| 1022 | + |
| 1023 | + main.innerHTML = ''; |
| 1024 | + var wrap = el('div', 'wiki-editor-wrap'); |
| 1025 | + |
| 1026 | + // Toolbar: title, save, cancel |
| 1027 | + var toolbar = el('div', 'wiki-editor-toolbar'); |
| 1028 | + var title = el('h2', 'wiki-editor-title'); |
| 1029 | + title.textContent = (data.meta && data.meta.title) || data.path; |
| 1030 | + var spacer = el('span', 'wiki-editor-spacer'); |
| 1031 | + var cancelBtn = el('button', 'wiki-editor-btn wiki-editor-cancel'); |
| 1032 | + cancelBtn.type = 'button'; |
| 1033 | + cancelBtn.textContent = 'Cancel'; |
| 1034 | + var saveBtn = el('button', 'wiki-editor-btn wiki-editor-save'); |
| 1035 | + saveBtn.type = 'button'; |
| 1036 | + saveBtn.textContent = 'Save'; |
| 1037 | + toolbar.appendChild(title); |
| 1038 | + toolbar.appendChild(spacer); |
| 1039 | + toolbar.appendChild(cancelBtn); |
| 1040 | + toolbar.appendChild(saveBtn); |
| 1041 | + wrap.appendChild(toolbar); |
| 1042 | + |
| 1043 | + // Split pane: left editor, right preview |
| 1044 | + var split = el('div', 'wiki-editor-split'); |
| 1045 | + var leftCol = el('div', 'wiki-editor-pane wiki-editor-source'); |
| 1046 | + var rightCol = el('div', 'wiki-editor-pane wiki-editor-preview'); |
| 1047 | + var previewBody = el('div', 'wiki-body wiki-preview-body'); |
| 1048 | + rightCol.appendChild(previewBody); |
| 1049 | + split.appendChild(leftCol); |
| 1050 | + split.appendChild(rightCol); |
| 1051 | + wrap.appendChild(split); |
| 1052 | + main.appendChild(wrap); |
| 1053 | + |
| 1054 | + // Reconstruct full source (frontmatter + body) so the user can |
| 1055 | + // edit metadata inline. If server gave us both, merge them. |
| 1056 | + var fullSource = _reconstructSource(data.meta || {}, data.body || ''); |
| 1057 | + |
| 1058 | + // Preview renderer with KaTeX |
| 1059 | + function rerender(src) { |
| 1060 | + var parts = _splitFrontmatter(src); |
| 1061 | + previewBody.innerHTML = renderMarkdown(parts.body); |
| 1062 | + if (window.renderMathInElement) { |
| 1063 | + try { |
| 1064 | + window.renderMathInElement(previewBody, { |
| 1065 | + delimiters: [ |
| 1066 | + { left: '$$', right: '$$', display: true }, |
| 1067 | + { left: '$', right: '$', display: false }, |
| 1068 | + { left: '\\(', right: '\\)', display: false }, |
| 1069 | + { left: '\\[', right: '\\]', display: true } |
| 1070 | + ], |
| 1071 | + throwOnError: false |
| 1072 | + }); |
| 1073 | + } catch (e) { /* noop */ } |
| 1074 | + } |
| 1075 | + } |
| 1076 | + |
| 1077 | + // Build CM6 state + view |
| 1078 | + var EditorState = mods.state.EditorState; |
| 1079 | + var EditorView = mods.view.EditorView; |
| 1080 | + var keymap = mods.view.keymap; |
| 1081 | + var basicSetup = mods.commands.history ? [mods.commands.history()] : []; |
| 1082 | + var markdownLang = mods.lang.markdown(); |
| 1083 | + var oneDark = mods.oneDark.oneDark; |
| 1084 | + var updateListener = EditorView.updateListener.of(function(upd) { |
| 1085 | + if (upd.docChanged) rerender(upd.state.doc.toString()); |
| 1086 | + }); |
| 1087 | + var cm = new EditorView({ |
| 1088 | + state: EditorState.create({ |
| 1089 | + doc: fullSource, |
| 1090 | + extensions: [ |
| 1091 | + markdownLang, |
| 1092 | + oneDark, |
| 1093 | + updateListener, |
| 1094 | + EditorView.lineWrapping |
| 1095 | + ] |
| 1096 | + }), |
| 1097 | + parent: leftCol |
| 1098 | + }); |
| 1099 | + rerender(fullSource); |
| 1100 | + |
| 1101 | + cancelBtn.addEventListener('click', function() { |
| 1102 | + if (!confirm('Discard changes?')) return; |
| 1103 | + loadPage(data.path); |
| 1104 | + }); |
| 1105 | + |
| 1106 | + saveBtn.addEventListener('click', async function() { |
| 1107 | + saveBtn.disabled = true; |
| 1108 | + saveBtn.textContent = 'Saving\u2026'; |
| 1109 | + var newSource = cm.state.doc.toString(); |
| 1110 | + try { |
| 1111 | + var resp = await fetch('/api/wiki/save', { |
| 1112 | + method: 'POST', |
| 1113 | + headers: { 'Content-Type': 'application/json' }, |
| 1114 | + body: JSON.stringify({ rel_path: data.path, body: newSource }) |
| 1115 | + }); |
| 1116 | + var result = await resp.json(); |
| 1117 | + if (!resp.ok || result.error) { |
| 1118 | + throw new Error(result.error || 'save failed'); |
| 1119 | + } |
| 1120 | + saveBtn.textContent = 'Saved'; |
| 1121 | + setTimeout(function() { loadPage(data.path); }, 300); |
| 1122 | + } catch (err) { |
| 1123 | + saveBtn.disabled = false; |
| 1124 | + saveBtn.textContent = 'Save'; |
| 1125 | + alert('Save failed: ' + err.message); |
| 1126 | + } |
| 1127 | + }); |
| 1128 | + } |
| 1129 | + |
| 1130 | + function _splitFrontmatter(src) { |
| 1131 | + // Returns {frontmatter: str|'', body: str}. Recognises the standard |
| 1132 | + // `---\n…\n---\n` envelope; preserves everything else as body. |
| 1133 | + if (!src.startsWith('---\n') && !src.startsWith('---\r\n')) { |
| 1134 | + return { frontmatter: '', body: src }; |
| 1135 | + } |
| 1136 | + var rest = src.slice(4); |
| 1137 | + var endRe = /(^|\n)---\s*(\n|$)/; |
| 1138 | + var m = endRe.exec(rest); |
| 1139 | + if (!m) return { frontmatter: '', body: src }; |
| 1140 | + var fm = rest.slice(0, m.index); |
| 1141 | + var body = rest.slice(m.index + m[0].length); |
| 1142 | + return { frontmatter: fm, body: body }; |
| 1143 | + } |
| 1144 | + |
| 1145 | + function _reconstructSource(meta, body) { |
| 1146 | + // Server gives us parsed frontmatter + body separately; rebuild the |
| 1147 | + // full source for editing. Users can edit frontmatter directly. |
| 1148 | + if (!meta || Object.keys(meta).length === 0) return body || ''; |
| 1149 | + var lines = ['---']; |
| 1150 | + Object.keys(meta).forEach(function(k) { |
| 1151 | + var v = meta[k]; |
| 1152 | + if (v === null || v === undefined || v === '') return; |
| 1153 | + if (Array.isArray(v)) { |
| 1154 | + lines.push(k + ': [' + v.map(function(x) { return String(x); }).join(', ') + ']'); |
| 1155 | + } else { |
| 1156 | + lines.push(k + ': ' + String(v)); |
| 1157 | + } |
| 1158 | + }); |
| 1159 | + lines.push('---', '', body || ''); |
| 1160 | + return lines.join('\n'); |
| 1161 | + } |
| 1162 | + |
956 | 1163 | // ── Init ── |
957 | 1164 | document.addEventListener('DOMContentLoaded', init); |
958 | 1165 | })(); |
0 commit comments