236 lines
9.1 KiB
JavaScript
236 lines
9.1 KiB
JavaScript
(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(); },
|
|
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); },
|
|
};
|
|
|
|
// 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); });
|
|
});
|
|
})();
|