From b3ca7145978ceae0365c2976cd3d9e7c0d33159d Mon Sep 17 00:00:00 2001 From: luxick Date: Wed, 15 Apr 2026 09:14:24 +0200 Subject: [PATCH] Improve markdown editor --- assets/editor.js | 166 ++++++++++++++++++++++-- assets/editor/dates.js | 17 +++ assets/editor/lists.js | 86 +++++++++++++ assets/editor/tables.js | 274 ++++++++++++++++++++++++++++++++++++++++ assets/page.html | 6 + assets/style.css | 29 +++++ 6 files changed, 564 insertions(+), 14 deletions(-) create mode 100644 assets/editor/dates.js create mode 100644 assets/editor/lists.js create mode 100644 assets/editor/tables.js 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;