435 lines
19 KiB
JavaScript
435 lines
19 KiB
JavaScript
// 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
|
|
// opts.onChange called after every change to the text
|
|
// 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,
|
|
opts.onChange ? CM.EditorView.updateListener.of(function (u) {
|
|
if (u.docChanged) opts.onChange();
|
|
}) : [],
|
|
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 <angle brackets> — 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 };
|
|
})();
|