// core.js — the markdown editor: the CodeMirror setup plus the formatting // actions behind the toolbar (editor/toolbar.html) and the Alt+Shift // shortcuts. Shared by the page editor (main.js) and the canvas node editor // (canvas/canvas.js). // // MarkdownEditor.create(mount, opts) → { view, actions, getValue, focus, destroy } // opts.doc initial text // opts.keymap extra CM key bindings, ahead of the defaults // MarkdownEditor.bind(root, getActions) // wires root's [data-action] buttons, [data-key] shortcuts and toolbar // dropdowns to whatever getActions() returns (null = no editor open). window.MarkdownEditor = (function () { var T = EditorTables; var D = EditorDates; var M = EditorMovie; // 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'); } // pageBase is the folder of the page being edited, slash-terminated: the // page URL itself, or for a canvas (/Page/Board.canvas) the folder it // lives in. Relative embed destinations resolve against it. function pageBase() { var p = decodeURIComponent(window.location.pathname); return p.slice(0, p.lastIndexOf('/') + 1); } function create(mount, opts) { opts = opts || {}; var doc = opts.doc || ''; var view; // 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 (v) { var result = fn(v.state.doc.toString(), v.state.selection.main.head); if (!result) return false; dispatchFullReplace(result); return true; }; } function tableNavKey(fn) { var handler = tableKey(fn); return function (v) { if (completionOpen()) return false; return handler(v); }; } 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: doc, // 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: doc.length }, extensions: [ // The caller's keys go first, ahead of even autocompletion's // own highest-precedence keys: those swallow an Escape while a // completion query is still pending, with no popup in sight. CM.Prec.highest(CM.keymap.of(opts.keymap || [])), 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] )), ], }); 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(); }, }; // 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 = pageBase(); 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 = pageBase().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 )); } } }); } }); } var actions = { 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); }, }; return { view: view, actions: actions, getValue: function () { return view.state.doc.toString(); }, focus: function () { view.focus(); }, destroy: function () { view.destroy(); }, }; } // 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'; } function bind(root, getActions) { function run(name) { var actions = getActions(); var action = actions && actions[name]; if (action) action(); } var keyMap = {}; root.querySelectorAll('[data-action]').forEach(function (btn) { btn.addEventListener('click', function () { run(btn.dataset.action); }); if (btn.dataset.key) keyMap[btn.dataset.key] = 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 = root.classList.contains('editor-toolbar') ? root : root.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 || !getActions()) 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 name = keyMap[key]; if (name) { e.preventDefault(); run(name); } }); root.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); }); }); } return { create: create, bind: bind }; })();