(function () { var mount = document.getElementById('editor'); var hidden = document.getElementById('editor-content'); if (!mount || !hidden || !window.CM) return; var form = hidden.closest('form'); var T = EditorTables; var D = EditorDates; var M = EditorMovie; // --- CodeMirror setup --- // Shift+Enter (new table row below) / Shift+Delete (delete table row) run at // highest precedence so they win over CM's default newline/forward-delete. // Returning false (no table at cursor) lets CM fall back to its default. function tableKey(fn) { return function (view) { var result = fn(view.state.doc.toString(), view.state.selection.main.head); if (!result) return false; dispatchFullReplace(result); return true; }; } // Enter/Tab cell navigation must yield to an open autocomplete popup so those // keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell). // The table keymap sits at Prec.highest, so without this guard it would // swallow the key before the completion keymap ever sees it. Detect the popup // via its tooltip element rather than exporting completionStatus from the // vendored bundle (which would force a bundle rebuild). function completionOpen() { return !!document.querySelector('.cm-tooltip-autocomplete'); } function tableNavKey(fn) { var handler = tableKey(fn); return function (view) { if (completionOpen()) return false; return handler(view); }; } var tableKeymap = [ { key: 'Shift-Enter', run: tableKey(T.insertRowBelow) }, { key: 'Shift-Delete', run: tableKey(T.deleteRow) }, { key: 'Enter', run: tableNavKey(T.nextRowSameColumn) }, { key: 'Tab', run: tableNavKey(T.nextCell) }, { key: 'Shift-Tab', run: tableNavKey(T.prevCell) }, ]; var state = CM.EditorState.create({ doc: hidden.value, // Start with the caret at the end of the existing content — editing an // existing page almost always means appending, and the default (start // of doc) buries the caret behind the leading heading. selection: { anchor: hidden.value.length }, extensions: [ CM.history(), CM.drawSelection(), CM.indentOnInput(), CM.EditorView.lineWrapping, // Enable native browser spellcheck on the contenteditable surface // (CM6 leaves it off by default). autocapitalize helps prose entry // on the Android/mobile path; CM's DOM observer absorbs corrections. CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }), CM.markdown({ base: CM.markdownLanguage }), CM.syntaxHighlighting(CM.highlightStyle), CM.closeBrackets(), CM.autocompletion({ override: [WikiComplete.source] }), CM.theme, CM.Prec.highest(CM.keymap.of(tableKeymap)), CM.keymap.of([].concat( CM.closeBracketsKeymap, CM.completionKeymap, CM.markdownKeymap, CM.defaultKeymap, CM.historyKeymap, [CM.indentWithTab] )), ], }); var view = new CM.EditorView({ state: state, parent: mount }); view.focus(); // --- CM document helpers --- function dispatchFullReplace(result) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: result.text }, selection: { anchor: result.cursor }, scrollIntoView: true, }); view.focus(); } function insertAtCursor(s) { var sel = view.state.selection.main; view.dispatch({ changes: { from: sel.from, to: sel.to, insert: s }, selection: { anchor: sel.from + s.length }, scrollIntoView: true, }); view.focus(); } function wrap(before, after, placeholder) { var sel = view.state.selection.main; var hadSelection = sel.to > sel.from; var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder; var insert = before + selected + after; var anchor, head; if (hadSelection) { anchor = head = sel.from + insert.length; } else { anchor = sel.from + before.length; head = anchor + placeholder.length; } view.dispatch({ changes: { from: sel.from, to: sel.to, insert: insert }, selection: { anchor: anchor, head: head }, scrollIntoView: true, }); view.focus(); } function linePrefix(prefix) { var sel = view.state.selection.main; var line = view.state.doc.lineAt(sel.from); view.dispatch({ changes: { from: line.from, to: line.from, insert: prefix }, selection: { anchor: sel.from + prefix.length }, scrollIntoView: true, }); view.focus(); } function applyTableOp(fn, arg) { var text = view.state.doc.toString(); var pos = view.state.selection.main.head; var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos); if (result) dispatchFullReplace(result); } // Adapter passed to movie.js so it reads/writes the CM document instead of a // textarea (replace() dispatches a transaction; cursor lands after the block). var movieCtx = { getValue: function () { return view.state.doc.toString(); }, replace: function (start, end, text) { view.dispatch({ changes: { from: start, to: end, insert: text }, selection: { anchor: start + text.length }, scrollIntoView: true, }); view.focus(); }, }; // --- Content sync --- // Serialize the CM document into the hidden textarea on submit only — Save // must not depend on CM focus or async state (decision 8). requestSubmit // fires this listener even for the ALT+SHIFT+S path. function syncContent() { hidden.value = view.state.doc.toString(); } // Save POSTs via fetch and then rewrites the *editor's* history entry with // the resulting page, so the edit session and its result share a single // entry (see history-nav.js). A plain form submit would push a second one // and leave the editor sitting in history behind the saved page. // // The server answers 204 + X-Target instead of a 303 because the target may // carry a #section anchor the client cannot compute, and fetch drops the // fragment from a followed redirect. function postSave() { syncContent(); var body = new URLSearchParams(new FormData(form)).toString(); fetch(form.action, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Save-Mode': 'replace', }, body: body, }).then(function (res) { if (!res.ok) { return res.text().then(function (msg) { alert(msg || ('Save failed (' + res.status + ')')); }); } var target = res.headers.get('X-Target') || form.action; // replaceState + reload rather than location.replace: if target // differs from the current URL only by fragment the browser would // skip the re-fetch and show pre-save content. window.history.replaceState(null, '', target); window.location.reload(); }).catch(function () { alert('Network error — the page was not saved'); }); } form.addEventListener('submit', function (e) { e.preventDefault(); postSave(); }); // --- Actions --- var actions = { save: function () { form.requestSubmit(); }, undo: function () { CM.undo(view); view.focus(); }, redo: function () { CM.redo(view); view.focus(); }, deleteline: function () { CM.deleteLine(view); view.focus(); }, bold: function () { wrap('**', '**', 'bold text'); }, italic: function () { wrap('*', '*', 'italic text'); }, h1: function () { linePrefix('# '); }, h2: function () { linePrefix('## '); }, h3: function () { linePrefix('### '); }, code: function () { wrap('`', '`', 'code'); }, codeblock: function () { wrap('```\n', '\n```', 'code'); }, quote: function () { linePrefix('> '); }, link: function () { wrap('[', '](url)', 'link text'); }, wikilink: insertWikilink, embed: insertEmbed, ul: function () { linePrefix('- '); }, ol: function () { linePrefix('1. '); }, task: function () { linePrefix('- [ ] '); }, hr: function () { wrap('\n\n---\n\n', '', ''); }, fmttable: function () { applyTableOp(T.formatTableText); }, tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); }, tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); }, tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); }, tblinsertcol: function () { applyTableOp(T.insertColumn); }, tbldeletecol: function () { applyTableOp(T.deleteColumn); }, tblinsertrow: function () { applyTableOp(T.insertRow); }, tbldeleterow: function () { applyTableOp(T.deleteRow); }, dateiso: function () { insertAtCursor(D.isoDate()); }, datelong: function () { insertAtCursor(D.longDate()); }, movie: function () { M.run(movieCtx); }, }; // Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead // plus an optional display-text field, inserting [[query]] or // [[query::display]] at the cursor. Picking a suggestion only fills in that // page's name — the link resolves through search at click time, so a path // never enters the document. Inline `[[` typing uses the same suggestions // via wikicomplete.js. function insertWikilink() { var sel = view.state.selection.main; var selectedText = view.state.sliceDoc(sel.from, sel.to).trim(); var container = document.createElement('div'); var queryWrap = document.createElement('div'); var queryInput = document.createElement('input'); queryInput.type = 'text'; queryInput.className = 'input'; queryInput.placeholder = 'Search query'; // A selection is the query, not the label: [[selection]] renders with // the same visible text anyway, and it seeds the typeahead. queryInput.value = selectedText; queryWrap.appendChild(queryInput); var displayInput = document.createElement('input'); displayInput.type = 'text'; displayInput.className = 'input'; displayInput.placeholder = 'Display text (optional)'; container.appendChild(queryWrap); container.appendChild(displayInput); var handle = openModal({ title: 'Insert link', body: container, confirm: { label: 'INSERT', initiallyDisabled: !selectedText, onConfirm: function () { var query = queryInput.value.trim(); if (!query) return; var display = displayInput.value.trim(); handle.close(); insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]'); } } }); function updateConfirm() { handle.setConfirmDisabled(!queryInput.value.trim()); } queryInput.addEventListener('input', updateConfirm); window.attachSuggestions(queryInput, { showFooter: false, container: queryWrap, onPick: function (r) { queryInput.value = r.name; updateConfirm(); displayInput.focus(); } }); } // embedDest turns the absolute wiki path the tree picker hands back into the // destination written to the document: relative to the page being edited // when the file lives under it, absolute otherwise. Pages render at a // slash-terminated URL, so a relative destination resolves against the page // in the browser. function embedDest(absPath) { var base = decodeURIComponent(window.location.pathname); if (base.slice(-1) !== '/') base += '/'; return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath; } // embedToken formats a Markdown image. A destination carrying spaces or // brackets goes in — CommonMark's way of keeping it in one // piece — and the title slot holds the alignment keyword the renderer reads. // Right is the renderer's default, so it is left off. function embedToken(dest, caption, align) { if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>'; var title = (align && align !== 'right') ? ' "' + align + '"' : ''; return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')'; } // Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a // caption and an alignment. Images render as a floated figure; anything // else degrades to a link. function insertEmbed() { var sel = view.state.selection.main; var selectedText = view.state.sliceDoc(sel.from, sel.to).trim(); var here = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/'; openTreePicker({ title: 'Embed — pick a file', mode: 'file', initialPath: here, confirmLabel: 'NEXT', onSelect: function (absPath) { var container = document.createElement('div'); var captionInput = document.createElement('input'); captionInput.type = 'text'; captionInput.className = 'input'; captionInput.placeholder = 'Caption (optional)'; captionInput.value = selectedText; var alignSelect = document.createElement('select'); alignSelect.className = 'input'; [['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']] .forEach(function (opt) { var o = document.createElement('option'); o.value = opt[0]; o.textContent = opt[1]; alignSelect.appendChild(o); }); container.appendChild(captionInput); container.appendChild(alignSelect); var handle = openModal({ title: 'Embed — caption?', body: container, confirm: { label: 'INSERT', onConfirm: function () { handle.close(); insertAtCursor(embedToken( embedDest(absPath), captionInput.value.trim(), alignSelect.value )); } } }); } }); } // --- Keyboard shortcut registration --- var keyMap = {}; document.querySelectorAll('[data-action]').forEach(function (btn) { btn.addEventListener('click', function () { var action = actions[btn.dataset.action]; if (action) action(); }); if (btn.dataset.key) { keyMap[btn.dataset.key] = actions[btn.dataset.action]; } }); // Keep the editor focused when a toolbar button is tapped. Without this the // button steals focus on mousedown, which dismisses the mobile soft keyboard // before the action runs (and view.focus() can't reopen it without a direct // gesture). preventDefault on mousedown blocks the focus shift; click still // fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes // dropdown toggles, which also must not pull focus off the editor. var toolbar = document.querySelector('.editor-toolbar'); if (toolbar) { toolbar.addEventListener('mousedown', function (e) { if (e.target.closest('.btn')) e.preventDefault(); }); } document.addEventListener('keydown', function (e) { if (!e.altKey || !e.shiftKey) return; // Shift+digit produces a layout-dependent character in e.key (e.g. "!" // on US, "!" on DE), so fall back to e.code for digit rows. var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key; var action = keyMap[key]; if (action) { e.preventDefault(); action(); } }); // --- Dropdowns --- // The toolbar scrolls horizontally (so it clips its absolutely-positioned // menus) and on mobile is fixed to the bottom of the viewport. Pin an open // menu to the viewport so it escapes the clip, opening upward when there // isn't room below it (the bottom-toolbar case). function pinMenu(toggle, menu) { if (!menu.classList.contains('is-open')) return; var r = toggle.getBoundingClientRect(); var vh = window.innerHeight; menu.style.position = 'fixed'; menu.style.overflowY = 'auto'; var spaceBelow = vh - r.bottom; var spaceAbove = r.top; if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) { menu.style.top = 'auto'; menu.style.bottom = (vh - r.top) + 'px'; menu.style.maxHeight = (spaceAbove - 8) + 'px'; } else { menu.style.bottom = 'auto'; menu.style.top = r.bottom + 'px'; menu.style.maxHeight = (spaceBelow - 8) + 'px'; } var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4); menu.style.left = Math.max(4, left) + 'px'; } document.querySelectorAll('.dropdown-toggle').forEach(function (toggle) { wireDropdown(toggle); var menu = toggle.parentElement.querySelector('.dropdown-menu'); if (!menu || !toolbar || !toolbar.contains(toggle)) return; // Runs after wireDropdown's own click handler has toggled is-open. toggle.addEventListener('click', function () { pinMenu(toggle, menu); }); }); })();