Files
datascape/assets/editor/main.js
T
2026-06-09 18:36:40 +02:00

308 lines
12 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); },
};
// 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];
}
});
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).
var toolbar = document.querySelector('.editor-toolbar');
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); });
});
})();