diff --git a/assets/editor.js b/assets/editor.js
index be8fdf5..0273797 100644
--- a/assets/editor.js
+++ b/assets/editor.js
@@ -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' },
+ ]);
+ }
})();
diff --git a/assets/editor/dates.js b/assets/editor/dates.js
new file mode 100644
index 0000000..fd58311
--- /dev/null
+++ b/assets/editor/dates.js
@@ -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 };
+})();
diff --git a/assets/editor/lists.js b/assets/editor/lists.js
new file mode 100644
index 0000000..805932e
--- /dev/null
+++ b/assets/editor/lists.js
@@ -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 };
+})();
diff --git a/assets/editor/tables.js b/assets/editor/tables.js
new file mode 100644
index 0000000..ce17765
--- /dev/null
+++ b/assets/editor/tables.js
@@ -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,
+ };
+})();
diff --git a/assets/page.html b/assets/page.html
index fefaa84..92a1d94 100644
--- a/assets/page.html
+++ b/assets/page.html
@@ -42,9 +42,15 @@
+
+
+
+
+
+
{{else}}
{{if .Content}}
diff --git a/assets/style.css b/assets/style.css
index 2607fa6..3313e25 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -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;