(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, // 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(); } 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(); }, 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, 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); }, }; // isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute // path, no empty/dot segments. Used to gate the modal's INSERT button. function isValidWikiTarget(p) { if (!p || p[0] !== '/') return false; var trimmed = p.replace(/^\/+|\/+$/g, ''); if (trimmed === '') return true; var segs = trimmed.split('/'); for (var i = 0; i < segs.length; i++) { if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false; } return true; } // Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by // full /_search typeahead plus an optional display-text field, then insert // [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses // the folder-scoped completion in wikicomplete.js instead.) function insertWikilink() { var sel = view.state.selection.main; var selectedText = view.state.sliceDoc(sel.from, sel.to); var container = document.createElement('div'); var targetWrap = document.createElement('div'); var targetInput = document.createElement('input'); targetInput.type = 'text'; targetInput.className = 'input'; targetInput.placeholder = 'Page path or search…'; targetWrap.appendChild(targetInput); var displayInput = document.createElement('input'); displayInput.type = 'text'; displayInput.className = 'input'; displayInput.placeholder = 'Display text (optional)'; if (selectedText) displayInput.value = selectedText; container.appendChild(targetWrap); container.appendChild(displayInput); var handle = openModal({ title: 'Insert link', body: container, confirm: { label: 'INSERT', initiallyDisabled: true, onConfirm: function () { var target = targetInput.value.trim(); if (!isValidWikiTarget(target)) return; var display = displayInput.value.trim(); handle.close(); insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]'); } } }); function updateConfirm() { handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim())); } targetInput.addEventListener('input', updateConfirm); window.attachSuggestions(targetInput, { showFooter: false, container: targetWrap, onPick: function (r) { targetInput.value = '/' + r.path; updateConfirm(); displayInput.focus(); displayInput.select(); } }); } // --- 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); }); }); })();