(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; }; } var tableKeymap = [ { key: 'Shift-Enter', run: tableKey(T.insertRowBelow) }, { key: 'Shift-Delete', run: tableKey(T.deleteRow) }, ]; var state = CM.EditorState.create({ doc: hidden.value, extensions: [ CM.history(), CM.drawSelection(), CM.indentOnInput(), CM.EditorView.lineWrapping, 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(); } form.addEventListener('submit', syncContent); // --- Actions --- var actions = { save: function () { form.requestSubmit(); }, undo: function () { CM.undo(view); view.focus(); }, redo: function () { CM.redo(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, 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); }, wide: function () { var enabled = !document.body.classList.contains('editor-wide'); document.body.classList.toggle('editor-wide', enabled); sessionStorage.setItem('editor-wide', enabled ? '1' : '0'); }, }; // Wiki link button: drop an empty [[]] at the cursor and open the `[[` // completion popup so the same autocomplete flow handles target selection. function insertWikilink() { var sel = view.state.selection.main; view.dispatch({ changes: { from: sel.from, to: sel.to, insert: '[[]]' }, selection: { anchor: sel.from + 2 }, scrollIntoView: true, }); view.focus(); CM.startCompletion(view); } // --- 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]; } }); 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 on mobile, which makes it an overflow // container that would clip its absolutely-positioned dropdown menus. Pin an // open menu to the viewport under its trigger so it escapes the clip. var toolbar = document.querySelector('.editor-toolbar'); function pinMenu(toggle, menu) { if (!menu.classList.contains('is-open')) return; var r = toggle.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.top = r.bottom + '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); }); }); })();