Use CodeMirror editor
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
window.EditorLists = (function () {
|
||||
|
||||
function detectListPrefix(lineText) {
|
||||
var m;
|
||||
m = lineText.match(/^(\s*)(- \[[ x]\] )/);
|
||||
if (m) return { indent: m[1], prefix: m[2], type: 'task' };
|
||||
m = lineText.match(/^(\s*)([-*+] )/);
|
||||
if (m) return { indent: m[1], prefix: m[2], type: 'unordered' };
|
||||
m = lineText.match(/^(\s*)(\d+)\. /);
|
||||
if (m) return { indent: m[1], prefix: m[2] + '. ', type: 'ordered', num: parseInt(m[2], 10) };
|
||||
m = lineText.match(/^(\s*)(> )/);
|
||||
if (m) return { indent: m[1], prefix: m[2], type: 'blockquote' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function continuationPrefix(info) {
|
||||
if (info.type === 'task') return info.indent + '- [ ] ';
|
||||
if (info.type === 'ordered') return info.indent + (info.num + 1) + '. ';
|
||||
return info.indent + info.prefix;
|
||||
}
|
||||
|
||||
function renumberOrderedList(text, fromLineIndex, indent, startNum) {
|
||||
var lines = text.split('\n');
|
||||
var re = new RegExp('^' + indent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\d+)\\. ');
|
||||
var num = startNum !== undefined ? startNum : null;
|
||||
for (var i = fromLineIndex; i < lines.length; i++) {
|
||||
var m = lines[i].match(re);
|
||||
if (!m) break;
|
||||
if (num === null) num = parseInt(m[1], 10);
|
||||
var newNumStr = String(num);
|
||||
if (m[1] !== newNumStr) {
|
||||
lines[i] = indent + newNumStr + '. ' + lines[i].slice(m[0].length);
|
||||
}
|
||||
num++;
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function handleEnterKey(text, cursorPos) {
|
||||
var before = text.slice(0, cursorPos);
|
||||
var after = text.slice(cursorPos);
|
||||
var lineStart = before.lastIndexOf('\n') + 1;
|
||||
var lineEnd = text.indexOf('\n', cursorPos);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
var fullLine = text.slice(lineStart, lineEnd);
|
||||
var info = detectListPrefix(fullLine);
|
||||
if (!info) return null;
|
||||
|
||||
var contentAfterPrefix = fullLine.slice(info.indent.length + info.prefix.length);
|
||||
if (contentAfterPrefix.trim() === '') {
|
||||
var newText = text.slice(0, lineStart) + '\n' + after;
|
||||
var newCursor = lineStart + 1;
|
||||
if (info.type === 'ordered') {
|
||||
var lineIndex = text.slice(0, lineStart).split('\n').length;
|
||||
newText = renumberOrderedList(newText, lineIndex, info.indent);
|
||||
}
|
||||
return { text: newText, cursor: newCursor };
|
||||
}
|
||||
|
||||
var cont = continuationPrefix(info);
|
||||
var newText = before + '\n' + cont + after;
|
||||
var newCursor = cursorPos + 1 + cont.length;
|
||||
if (info.type === 'ordered') {
|
||||
var insertedLineIndex = before.split('\n').length;
|
||||
newText = renumberOrderedList(newText, insertedLineIndex, info.indent);
|
||||
}
|
||||
return { text: newText, cursor: newCursor };
|
||||
}
|
||||
|
||||
function deleteOrderedLine(text, cursorPos) {
|
||||
var lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1;
|
||||
var lineEnd = text.indexOf('\n', cursorPos);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
var fullLine = text.slice(lineStart, lineEnd);
|
||||
var info = detectListPrefix(fullLine);
|
||||
if (!info || info.type !== 'ordered') return null;
|
||||
|
||||
var newText = text.slice(0, lineStart) + text.slice(lineEnd === text.length ? lineEnd : lineEnd + 1);
|
||||
var newCursor = lineStart;
|
||||
var fromLineIndex = text.slice(0, lineStart).split('\n').length - 1;
|
||||
newText = renumberOrderedList(newText, fromLineIndex, info.indent, info.num);
|
||||
return { text: newText, cursor: Math.min(newCursor, newText.length) };
|
||||
}
|
||||
|
||||
return { handleEnterKey: handleEnterKey, deleteOrderedLine: deleteOrderedLine };
|
||||
})();
|
||||
@@ -71,11 +71,13 @@
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="wide" data-key="Z" title="Toggle wide mode (Z)">⇔</button>
|
||||
</div>
|
||||
<textarea class="input editor-textarea" name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
||||
<div id="editor" class="editor-cm"></div>
|
||||
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
|
||||
</form>
|
||||
<script src="/_/editor/lists.js"></script>
|
||||
<script src="/_/editor/vendor/codemirror.bundle.js"></script>
|
||||
<script src="/_/editor/tables.js"></script>
|
||||
<script src="/_/editor/dates.js"></script>
|
||||
<script src="/_/editor/movie.js"></script>
|
||||
<script src="/_/editor/wikicomplete.js"></script>
|
||||
<script src="/_/editor/main.js"></script>
|
||||
{{end}}
|
||||
|
||||
+150
-160
@@ -1,145 +1,147 @@
|
||||
(function () {
|
||||
var textarea = document.getElementById('editor');
|
||||
if (!textarea) return;
|
||||
var mount = document.getElementById('editor');
|
||||
var hidden = document.getElementById('editor-content');
|
||||
if (!mount || !hidden || !window.CM) return;
|
||||
|
||||
var form = textarea.closest('form');
|
||||
|
||||
// --- DOM helpers ---
|
||||
|
||||
// Route every edit through execCommand so the browser's native undo/redo
|
||||
// stack is preserved. Direct assignment to textarea.value would wipe it.
|
||||
function replaceRange(start, end, text) {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = start;
|
||||
textarea.selectionEnd = end;
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
|
||||
function wrap(before, after, placeholder) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var hadSelection = end > start;
|
||||
var selected = hadSelection ? textarea.value.slice(start, end) : placeholder;
|
||||
replaceRange(start, end, before + selected + after);
|
||||
if (!hadSelection) {
|
||||
textarea.selectionStart = start + before.length;
|
||||
textarea.selectionEnd = start + before.length + placeholder.length;
|
||||
}
|
||||
}
|
||||
|
||||
function linePrefix(prefix) {
|
||||
var start = textarea.selectionStart;
|
||||
var lineStart = textarea.value.lastIndexOf('\n', start - 1) + 1;
|
||||
replaceRange(lineStart, lineStart, prefix);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + prefix.length;
|
||||
}
|
||||
|
||||
function insertAtCursor(s) {
|
||||
replaceRange(textarea.selectionStart, textarea.selectionEnd, s);
|
||||
}
|
||||
|
||||
function applyResult(result) {
|
||||
var oldText = textarea.value;
|
||||
var newText = result.text;
|
||||
var prefixLen = 0;
|
||||
var maxPrefix = Math.min(oldText.length, newText.length);
|
||||
while (prefixLen < maxPrefix && oldText.charCodeAt(prefixLen) === newText.charCodeAt(prefixLen)) {
|
||||
prefixLen++;
|
||||
}
|
||||
var oldEnd = oldText.length;
|
||||
var newEnd = newText.length;
|
||||
while (oldEnd > prefixLen && newEnd > prefixLen
|
||||
&& oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
|
||||
oldEnd--;
|
||||
newEnd--;
|
||||
}
|
||||
replaceRange(prefixLen, oldEnd, newText.slice(prefixLen, newEnd));
|
||||
textarea.selectionStart = textarea.selectionEnd = result.cursor;
|
||||
}
|
||||
|
||||
function applyTableOp(fn, arg) {
|
||||
var result = arg !== undefined
|
||||
? fn(textarea.value, textarea.selectionStart, arg)
|
||||
: fn(textarea.value, textarea.selectionStart);
|
||||
if (result) applyResult(result);
|
||||
}
|
||||
|
||||
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
||||
// path, no empty/dot segments. Used to gate the INSERT confirm 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;
|
||||
}
|
||||
|
||||
function insertWikilink() {
|
||||
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||
|
||||
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 (sel) displayInput.value = sel;
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
var form = hidden.closest('form');
|
||||
|
||||
var T = EditorTables;
|
||||
var L = EditorLists;
|
||||
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.submit(); },
|
||||
save: function () { form.requestSubmit(); },
|
||||
bold: function () { wrap('**', '**', 'bold text'); },
|
||||
italic: function () { wrap('*', '*', 'italic text'); },
|
||||
h1: function () { linePrefix('# '); },
|
||||
@@ -164,7 +166,7 @@
|
||||
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
||||
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||
datelong: function () { insertAtCursor(D.longDate()); },
|
||||
movie: function () { M.run(textarea); },
|
||||
movie: function () { M.run(movieCtx); },
|
||||
wide: function () {
|
||||
var enabled = !document.body.classList.contains('editor-wide');
|
||||
document.body.classList.toggle('editor-wide', enabled);
|
||||
@@ -172,6 +174,19 @@
|
||||
},
|
||||
};
|
||||
|
||||
// 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 = {};
|
||||
@@ -197,31 +212,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// --- Textarea key handling ---
|
||||
|
||||
textarea.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Delete' && e.shiftKey) {
|
||||
var result = T.deleteRow(textarea.value, textarea.selectionStart)
|
||||
|| L.deleteOrderedLine(textarea.value, textarea.selectionStart);
|
||||
if (!result) return;
|
||||
e.preventDefault();
|
||||
applyResult(result);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
var result = T.insertRowBelow(textarea.value, textarea.selectionStart);
|
||||
if (!result) return;
|
||||
e.preventDefault();
|
||||
applyResult(result);
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Enter') return;
|
||||
var result = L.handleEnterKey(textarea.value, textarea.selectionStart);
|
||||
if (!result) return;
|
||||
e.preventDefault();
|
||||
applyResult(result);
|
||||
});
|
||||
|
||||
// --- Dropdowns ---
|
||||
|
||||
document.querySelectorAll('.dropdown-toggle').forEach(wireDropdown);
|
||||
|
||||
+13
-19
@@ -48,26 +48,20 @@ window.EditorMovie = (function () {
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function replaceRange(ta, start, end, text) {
|
||||
ta.focus();
|
||||
ta.selectionStart = start;
|
||||
ta.selectionEnd = end;
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
|
||||
function insertOrReplace(ta, markup) {
|
||||
var t = ta.value || '';
|
||||
// ctx is the CM adapter from main.js: { getValue(), replace(start,end,text) }.
|
||||
function insertOrReplace(ctx, markup) {
|
||||
var t = ctx.getValue() || '';
|
||||
var b = t.indexOf(BEGIN);
|
||||
var e = t.indexOf(END);
|
||||
if (b !== -1 && e !== -1 && e > b) {
|
||||
replaceRange(ta, b, e + END.length, markup);
|
||||
ctx.replace(b, e + END.length, markup);
|
||||
} else {
|
||||
var h = t.match(/^#{1,6}\s+.+?\s*$/m);
|
||||
if (h) {
|
||||
var idx = t.indexOf(h[0]) + h[0].length;
|
||||
replaceRange(ta, idx, idx, '\n\n' + markup);
|
||||
ctx.replace(idx, idx, '\n\n' + markup);
|
||||
} else {
|
||||
replaceRange(ta, 0, 0, t ? markup + '\n\n' : markup);
|
||||
ctx.replace(0, 0, t ? markup + '\n\n' : markup);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +119,7 @@ window.EditorMovie = (function () {
|
||||
});
|
||||
}
|
||||
|
||||
function importWithKey(textarea, key, initialTitle) {
|
||||
function importWithKey(ctx, key, initialTitle) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'input';
|
||||
@@ -148,7 +142,7 @@ window.EditorMovie = (function () {
|
||||
data.Error === 'Invalid API key!') {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
promptForKey(true, function (newKey) {
|
||||
importWithKey(textarea, newKey, raw);
|
||||
importWithKey(ctx, newKey, raw);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -157,7 +151,7 @@ window.EditorMovie = (function () {
|
||||
(data && data.Error) || 'Movie not found.');
|
||||
return;
|
||||
}
|
||||
insertOrReplace(textarea, buildBlock(data));
|
||||
insertOrReplace(ctx, buildBlock(data));
|
||||
})
|
||||
.catch(function () {
|
||||
showMessage('Import failed', 'OMDb lookup failed.');
|
||||
@@ -167,16 +161,16 @@ window.EditorMovie = (function () {
|
||||
});
|
||||
}
|
||||
|
||||
function run(textarea) {
|
||||
var initialTitle = firstHeading(textarea.value || '');
|
||||
function run(ctx) {
|
||||
var initialTitle = firstHeading(ctx.getValue() || '');
|
||||
var key = localStorage.getItem(STORAGE_KEY);
|
||||
if (!key) {
|
||||
promptForKey(false, function (newKey) {
|
||||
importWithKey(textarea, newKey, initialTitle);
|
||||
importWithKey(ctx, newKey, initialTitle);
|
||||
});
|
||||
return;
|
||||
}
|
||||
importWithKey(textarea, key, initialTitle);
|
||||
importWithKey(ctx, key, initialTitle);
|
||||
}
|
||||
|
||||
return { run: run };
|
||||
|
||||
+27
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
|
||||
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
|
||||
//
|
||||
// Triggers when the cursor sits in a `[[…` token (freshly typed or re-edited
|
||||
// inside an existing `[[…]]`), queries the existing /_search JSON endpoint
|
||||
// (debounced), and offers matching page paths. Selecting a result inserts the
|
||||
// absolute path and auto-closes `]]`, leaving the cursor before the close so a
|
||||
// `::display` alias can be typed (decision 7). Exposes window.WikiComplete.source
|
||||
// for main.js to register via CM's autocompletion().
|
||||
window.WikiComplete = (function () {
|
||||
var DEBOUNCE_MS = 100;
|
||||
var MIN_QUERY_LEN = 2;
|
||||
|
||||
// Mirrors the Go validator (wikilinks.go isValidWikiTarget) and the old
|
||||
// client check: absolute path, no empty/'.'/'..' segments. Server results
|
||||
// are wiki paths so this is mostly a guard against odd index entries.
|
||||
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;
|
||||
}
|
||||
|
||||
// Build the apply() for a chosen result. Inserts the path over the typed
|
||||
// query and ensures a single trailing `]]`, cursor parked before it. If the
|
||||
// user (or closeBrackets) already produced `]]`, we don't double it up.
|
||||
function makeApply(path) {
|
||||
return function (view, completion, from, to) {
|
||||
var closed = view.state.sliceDoc(to, to + 2) === ']]';
|
||||
var insert = closed ? path : path + ']]';
|
||||
view.dispatch({
|
||||
changes: { from: from, to: to, insert: insert },
|
||||
selection: { anchor: from + path.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function fetchSuggest(query) {
|
||||
return fetch('/_search?q=' + encodeURIComponent(query), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
// CM completion source. `[[` plus any run of non-`]`, non-newline chars
|
||||
// before the cursor is the trigger; the query is the text after `[[`. The
|
||||
// replaced range extends past the cursor to the end of the inner token (up
|
||||
// to `]]`/EOL) so re-editing inside an existing `[[…]]` replaces the whole
|
||||
// target instead of duplicating the trailing text.
|
||||
function source(context) {
|
||||
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
||||
if (!match) return null;
|
||||
var query = context.state.sliceDoc(match.from + 2, context.pos);
|
||||
if (query.length < MIN_QUERY_LEN && !context.explicit) return null;
|
||||
|
||||
var line = context.state.doc.lineAt(context.pos);
|
||||
var to = context.pos;
|
||||
while (to < line.to && context.state.sliceDoc(to, to + 1) !== ']') to++;
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
var timer = setTimeout(function () {
|
||||
if (context.aborted) { resolve(null); return; }
|
||||
fetchSuggest(query).then(function (resp) {
|
||||
if (context.aborted) { resolve(null); return; }
|
||||
var options = (resp.results || []).reduce(function (acc, r) {
|
||||
var path = '/' + r.path;
|
||||
if (isValidWikiTarget(path)) {
|
||||
acc.push({ label: path, detail: r.name, apply: makeApply(path) });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
resolve({ from: match.from + 2, to: to, options: options });
|
||||
}).catch(function () {
|
||||
resolve(null);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
// CM doesn't cancel pending promises, but it sets context.aborted;
|
||||
// clear the timer too if the doc moved on before it fired.
|
||||
if (context.addEventListener) {
|
||||
context.addEventListener('abort', function () { clearTimeout(timer); });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { source: source, isValidWikiTarget: isValidWikiTarget };
|
||||
})();
|
||||
+7
-12
@@ -167,7 +167,7 @@ footer {
|
||||
.btn::after { content: "]"; color: var(--secondary); }
|
||||
.btn:hover { color: var(--primary-hover); }
|
||||
.btn-small { font-size: 0.65rem; font-weight: normal; vertical-align: middle; }
|
||||
.btn-tool { font-size: var(--font-sm); padding: 0 0.15rem; }
|
||||
.btn-tool { padding: 0 0.15rem; }
|
||||
.btn-block {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -372,16 +372,11 @@ main > h2 {
|
||||
/* === Edit form === */
|
||||
.edit-form { display: flex; flex-direction: column; }
|
||||
body.editor-wide .page-wrap { max-width: none; }
|
||||
.editor-textarea {
|
||||
min-height: 60vh;
|
||||
background: var(--bg);
|
||||
border-top: none;
|
||||
font-family: "Iosevka Slab", monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
padding: var(--space-4);
|
||||
resize: vertical;
|
||||
}
|
||||
/* CodeMirror mount. The .cm-editor visual treatment (border, bg, font, padding)
|
||||
lives in the CM theme (editor-build/entry.js), keyed off the same :root
|
||||
variables; this only sizes the container. */
|
||||
.editor-cm { min-height: 60vh; }
|
||||
.editor-cm .cm-editor { height: 100%; }
|
||||
|
||||
/* === Search === */
|
||||
.search-form {
|
||||
@@ -678,7 +673,7 @@ aside.sidebar:empty { display: none; }
|
||||
@media (max-width: 600px) {
|
||||
header, footer { padding: var(--space-2) var(--space-3); }
|
||||
main { padding: var(--space-4) var(--space-3); }
|
||||
.editor-textarea { min-height: 50vh; }
|
||||
.editor-cm { min-height: 50vh; }
|
||||
.sidebar { width: calc(100% - 1.5rem); }
|
||||
.modal-backdrop { padding: var(--space-2); align-items: flex-start; }
|
||||
.modal { max-width: none; margin-top: var(--space-4); }
|
||||
|
||||
Reference in New Issue
Block a user