Improve markdown editor

This commit is contained in:
2026-04-15 09:14:24 +02:00
parent c7d5db7af7
commit b3ca714597
6 changed files with 564 additions and 14 deletions

View File

@@ -2,6 +2,10 @@
var textarea = document.getElementById('editor');
if (!textarea) return;
var form = textarea.closest('form');
// --- DOM helpers ---
function wrap(before, after, placeholder) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
@@ -26,24 +30,63 @@
textarea.focus();
}
var form = textarea.closest('form');
function insertAtCursor(s) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
textarea.value = textarea.value.slice(0, start) + s + textarea.value.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + s.length;
textarea.dispatchEvent(new Event('input'));
textarea.focus();
}
function applyResult(result) {
textarea.value = result.text;
textarea.selectionStart = textarea.selectionEnd = result.cursor;
textarea.dispatchEvent(new Event('input'));
textarea.focus();
}
function applyTableOp(fn, arg) {
var result = arg !== undefined
? fn(textarea.value, textarea.selectionStart, arg)
: fn(textarea.value, textarea.selectionStart);
if (result) applyResult(result);
}
// --- Actions ---
var T = EditorTables;
var L = EditorLists;
var D = EditorDates;
var actions = {
save: function () { form.submit(); },
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'); },
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
hr: function () { wrap('\n\n---\n\n', '', ''); },
save: function () { form.submit(); },
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'); },
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
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()); },
};
// --- Keyboard shortcut registration ---
var keyMap = {};
document.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
@@ -55,6 +98,10 @@
}
});
keyMap['T'] = actions.fmttable;
keyMap['D'] = actions.dateiso;
keyMap['W'] = actions.datelong;
document.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey) return;
var action = keyMap[e.key];
@@ -63,4 +110,95 @@
action();
}
});
// --- 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);
});
// --- Dropdown helper ---
var openMenus = [];
function makeDropdown(triggerBtn, items) {
var menu = document.createElement('div');
menu.className = 'toolbar-dropdown-menu';
items.forEach(function (item) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-tool toolbar-dropdown-item';
btn.textContent = item.label;
btn.addEventListener('mousedown', function (e) {
e.preventDefault();
actions[item.action]();
menu.classList.remove('is-open');
});
menu.appendChild(btn);
});
triggerBtn.appendChild(menu);
openMenus.push(menu);
triggerBtn.addEventListener('click', function (e) {
if (e.target !== triggerBtn) return;
var wasOpen = menu.classList.contains('is-open');
openMenus.forEach(function (m) { m.classList.remove('is-open'); });
if (!wasOpen) menu.classList.add('is-open');
});
}
document.addEventListener('click', function (e) {
var insideAny = openMenus.some(function (m) {
return m.parentElement && m.parentElement.contains(e.target);
});
if (!insideAny) openMenus.forEach(function (m) { m.classList.remove('is-open'); });
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') openMenus.forEach(function (m) { m.classList.remove('is-open'); });
});
// --- Table dropdown ---
var tblDropBtn = document.querySelector('[data-action="tbldrop"]');
if (tblDropBtn) {
makeDropdown(tblDropBtn, [
{ label: 'Format table', action: 'fmttable' },
{ label: 'Align left', action: 'tblalignleft' },
{ label: 'Align center', action: 'tblaligncenter' },
{ label: 'Align right', action: 'tblalignright' },
{ label: 'Insert column', action: 'tblinsertcol' },
{ label: 'Delete column', action: 'tbldeletecol' },
{ label: 'Insert row', action: 'tblinsertrow' },
{ label: 'Delete row', action: 'tbldeleterow' },
]);
}
// --- Date dropdown ---
var dateDropBtn = document.querySelector('[data-action="datedrop"]');
if (dateDropBtn) {
makeDropdown(dateDropBtn, [
{ label: 'YYYY-MM-DD', action: 'dateiso' },
{ label: 'DE Long', action: 'datelong' },
]);
}
})();

17
assets/editor/dates.js Normal file
View File

@@ -0,0 +1,17 @@
window.EditorDates = (function () {
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
function isoDate() {
var d = new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function longDate() {
return new Date().toLocaleDateString('de-DE', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
}
return { isoDate: isoDate, longDate: longDate };
})();

86
assets/editor/lists.js Normal file
View File

@@ -0,0 +1,86 @@
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 };
})();

274
assets/editor/tables.js Normal file
View File

@@ -0,0 +1,274 @@
window.EditorTables = (function () {
function repeat(ch, n) {
var s = '';
for (var i = 0; i < n; i++) s += ch;
return s;
}
function padLeft(s, w) {
while (s.length < w) s = ' ' + s;
return s;
}
function padRight(s, w) {
while (s.length < w) s += ' ';
return s;
}
function padCenter(s, w) {
while (s.length < w) {
s = s + ' ';
if (s.length < w) s = ' ' + s;
}
return s;
}
function parseTableRow(line) {
var trimmed = line.trim();
if (trimmed.charAt(0) === '|') trimmed = trimmed.slice(1);
if (trimmed.charAt(trimmed.length - 1) === '|') trimmed = trimmed.slice(0, -1);
return trimmed.split('|').map(function (c) { return c.trim(); });
}
function isSeparatorRow(cells) {
return cells.every(function (c) { return /^:?-+:?$/.test(c); });
}
function parseAlignment(cell) {
var left = cell.charAt(0) === ':';
var right = cell.charAt(cell.length - 1) === ':';
if (left && right) return 'center';
if (left) return 'left';
if (right) return 'right';
return null;
}
function makeSepCell(width, align) {
if (align === 'left') return ':' + repeat('-', width - 1);
if (align === 'center') return ':' + repeat('-', width - 2) + ':';
if (align === 'right') return repeat('-', width - 1) + ':';
return repeat('-', width);
}
function findTableRange(text, cursorPos) {
var lines = text.split('\n');
var charCount = 0;
var cursorLine = 0;
for (var i = 0; i < lines.length; i++) {
if (charCount + lines[i].length >= cursorPos && charCount <= cursorPos) {
cursorLine = i;
}
charCount += lines[i].length + 1;
}
if (!/^\|.*\|$/.test(lines[cursorLine].trim())) return null;
var start = cursorLine;
while (start > 0 && /^\|.*\|$/.test(lines[start - 1].trim())) start--;
var end = cursorLine;
while (end < lines.length - 1 && /^\|.*\|$/.test(lines[end + 1].trim())) end++;
if (end - start < 1) return null;
return { start: start, end: end, lines: lines, cursorLine: cursorLine };
}
function formatTableText(text, cursorPos) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var rows = [];
var sepIndex = -1;
var alignments = [];
for (var i = range.start; i <= range.end; i++) {
var cells = parseTableRow(range.lines[i]);
if (sepIndex === -1 && isSeparatorRow(cells)) {
sepIndex = rows.length;
alignments = cells.map(function (c) { return parseAlignment(c); });
}
rows.push(cells);
}
if (sepIndex === -1) return null;
var colCount = 0;
rows.forEach(function (r) { if (r.length > colCount) colCount = r.length; });
while (alignments.length < colCount) alignments.push(null);
rows = rows.map(function (r) {
while (r.length < colCount) r.push('');
return r;
});
var widths = [];
for (var c = 0; c < colCount; c++) {
var max = 3;
for (var r = 0; r < rows.length; r++) {
if (r === sepIndex) continue;
if (rows[r][c].length > max) max = rows[r][c].length;
}
widths.push(max);
}
var formatted = rows.map(function (row, ri) {
var cells = row.map(function (cell, ci) {
var w = widths[ci];
if (ri === sepIndex) return makeSepCell(w, alignments[ci]);
var align = alignments[ci];
if (align === 'right') return padLeft(cell, w);
if (align === 'center') return padCenter(cell, w);
return padRight(cell, w);
});
return '| ' + cells.join(' | ') + ' |';
});
var beforeTable = range.lines.slice(0, range.start).join('\n');
var afterTable = range.lines.slice(range.end + 1).join('\n');
var parts = [];
if (beforeTable) parts.push(beforeTable);
parts.push(formatted.join('\n'));
if (afterTable) parts.push(afterTable);
var newText = parts.join('\n');
var oldBeforeLen = 0;
for (var i = 0; i < range.start; i++) oldBeforeLen += range.lines[i].length + 1;
var cursorInTable = cursorPos - oldBeforeLen;
var newTableText = formatted.join('\n');
var newCursor = (beforeTable ? beforeTable.length + 1 : 0) + Math.min(cursorInTable, newTableText.length);
return { text: newText, cursor: newCursor };
}
function getCursorColumn(text, cursorPos) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var lines = text.split('\n');
var charCount = 0;
for (var i = 0; i < range.cursorLine; i++) charCount += lines[i].length + 1;
var lineOffset = cursorPos - charCount;
var line = lines[range.cursorLine];
var col = -1;
for (var c = 0; c < line.length; c++) {
if (line.charAt(c) === '|') {
col++;
if (c >= lineOffset) return Math.max(col - 1, 0);
}
}
return Math.max(col, 0);
}
function setColumnAlignment(text, cursorPos, align) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var colIdx = getCursorColumn(text, cursorPos);
if (colIdx === null) return null;
var rows = [];
var sepIndex = -1;
for (var i = range.start; i <= range.end; i++) {
var cells = parseTableRow(range.lines[i]);
if (sepIndex === -1 && isSeparatorRow(cells)) sepIndex = rows.length;
rows.push(cells);
}
if (sepIndex === -1 || colIdx >= rows[sepIndex].length) return null;
var cell = rows[sepIndex][colIdx].replace(/:/g, '-');
if (align === 'left') cell = ':' + cell.slice(1);
else if (align === 'center') cell = ':' + cell.slice(1, -1) + ':';
else if (align === 'right') cell = cell.slice(0, -1) + ':';
rows[sepIndex][colIdx] = cell;
var newLines = range.lines.slice();
for (var i = 0; i < rows.length; i++) {
newLines[range.start + i] = '| ' + rows[i].join(' | ') + ' |';
}
return formatTableText(newLines.join('\n'), cursorPos);
}
function insertColumn(text, cursorPos) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var colIdx = getCursorColumn(text, cursorPos);
if (colIdx === null) return null;
var newLines = range.lines.slice();
var sepIndex = -1;
for (var i = range.start; i <= range.end; i++) {
var cells = parseTableRow(range.lines[i]);
var isSep = sepIndex === -1 && isSeparatorRow(cells);
if (isSep) sepIndex = i;
cells.splice(colIdx, 0, isSep ? '---' : '');
newLines[i] = '| ' + cells.join(' | ') + ' |';
}
return formatTableText(newLines.join('\n'), cursorPos);
}
function deleteColumn(text, cursorPos) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var colIdx = getCursorColumn(text, cursorPos);
if (colIdx === null) return null;
var newLines = range.lines.slice();
for (var i = range.start; i <= range.end; i++) {
var cells = parseTableRow(range.lines[i]);
if (cells.length <= 1) return null;
cells.splice(colIdx, 1);
newLines[i] = '| ' + cells.join(' | ') + ' |';
}
return formatTableText(newLines.join('\n'), cursorPos);
}
function insertRow(text, cursorPos) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var colCount = 0;
for (var i = range.start; i <= range.end; i++) {
var cells = parseTableRow(range.lines[i]);
if (cells.length > colCount) colCount = cells.length;
}
var emptyCells = [];
for (var c = 0; c < colCount; c++) emptyCells.push('');
var newLines = range.lines.slice();
newLines.splice(range.cursorLine + 1, 0, '| ' + emptyCells.join(' | ') + ' |');
return formatTableText(newLines.join('\n'), cursorPos);
}
function insertRowBelow(text, cursorPos) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var result = insertRow(text, cursorPos);
if (!result) return null;
var lines = result.text.split('\n');
var cursor = 0;
for (var i = 0; i <= range.cursorLine; i++) cursor += lines[i].length + 1;
cursor += 2; // skip leading '| '
return { text: result.text, cursor: cursor };
}
function deleteRow(text, cursorPos) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var sepIndex = -1;
for (var i = range.start; i <= range.end; i++) {
if (isSeparatorRow(parseTableRow(range.lines[i]))) { sepIndex = i; break; }
}
if (range.cursorLine === range.start || range.cursorLine === sepIndex) return null;
var newLines = range.lines.slice();
newLines.splice(range.cursorLine, 1);
var newCursor = cursorPos;
if (range.cursorLine < newLines.length) {
var charCount = 0;
for (var i = 0; i < range.cursorLine; i++) charCount += newLines[i].length + 1;
newCursor = charCount;
}
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
}
return {
formatTableText: formatTableText,
setColumnAlignment: setColumnAlignment,
insertColumn: insertColumn,
deleteColumn: deleteColumn,
insertRow: insertRow,
insertRowBelow: insertRowBelow,
deleteRow: deleteRow,
};
})();

View File

@@ -42,9 +42,15 @@
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool toolbar-dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
<button type="button" class="btn btn-tool toolbar-dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
</div>
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
</form>
<script src="/_/editor/lists.js"></script>
<script src="/_/editor/tables.js"></script>
<script src="/_/editor/dates.js"></script>
<script src="/_/editor.js"></script>
{{else}}
{{if .Content}}

View File

@@ -274,6 +274,35 @@ main {
align-self: stretch;
}
/* === Toolbar dropdowns === */
.toolbar-dropdown {
position: relative;
}
.toolbar-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
display: none;
z-index: 100;
background: #001a00;
border: 1px solid #0a0;
min-width: 9rem;
}
.toolbar-dropdown-menu.is-open {
display: block;
}
.toolbar-dropdown-item {
display: block;
width: 100%;
text-align: left;
border: none;
border-radius: 0;
padding: 0.2rem 0.5rem;
}
/* === Edit form === */
.edit-form {
display: flex;