From 94845349eca95c8f20fc0a83adfcf322de7ebf53 Mon Sep 17 00:00:00 2001 From: Jordan Robinson Date: Wed, 17 Sep 2025 22:25:36 +0100 Subject: [PATCH] update markdown auto formatter to include many more keyboard shortcuts --- ...ve_user_markdown_link_paste_on_and_more.py | 22 ++ main/models.py | 6 +- main/templates/assets/markdown-autoformat.js | 214 ++++++++++++++++++ main/templates/assets/markdown-paste-link.js | 30 --- main/templates/main/page_form.html | 4 +- main/templates/main/post_form.html | 4 +- main/tests/test_users.py | 26 ++- main/views/general.py | 2 +- 8 files changed, 259 insertions(+), 49 deletions(-) create mode 100644 main/migrations/0119_remove_user_markdown_link_paste_on_and_more.py create mode 100644 main/templates/assets/markdown-autoformat.js delete mode 100644 main/templates/assets/markdown-paste-link.js diff --git a/main/migrations/0119_remove_user_markdown_link_paste_on_and_more.py b/main/migrations/0119_remove_user_markdown_link_paste_on_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..b1449a01b643cfa547aa7b1201cc4d3993f2bcde --- /dev/null +++ b/main/migrations/0119_remove_user_markdown_link_paste_on_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.5 on 2025-09-17 21:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0118_user_markdown_link_paste_on_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='markdown_link_paste_on', + ), + migrations.AddField( + model_name='user', + name='markdown_auto_format_on', + field=models.BooleanField(default=False, help_text='Enable/disable automatic markdown formatting.', verbose_name='Auto Markdown formatting'), + ), + ] diff --git a/main/models.py b/main/models.py index b2603f0e8118007d428139ec592bfead25292e05..67450c14ab564810cb050a86cdf38922694520d7 100644 --- a/main/models.py +++ b/main/models.py @@ -156,10 +156,10 @@ class User(AbstractUser): verbose_name="Webring next URL", help_text="URL for your webring's next website.", ) - markdown_link_paste_on = models.BooleanField( + markdown_auto_format_on = models.BooleanField( default=False, - help_text="Enable/disable automatic markdown link formatting on paste.", - verbose_name="Auto Markdown link formatting", + help_text="Enable/disable automatic markdown formatting.", + verbose_name="Auto Markdown formatting", ) # billing diff --git a/main/templates/assets/markdown-autoformat.js b/main/templates/assets/markdown-autoformat.js new file mode 100644 index 0000000000000000000000000000000000000000..377c75b34d487d58790ea24818cf5a61c6e91fb4 --- /dev/null +++ b/main/templates/assets/markdown-autoformat.js @@ -0,0 +1,214 @@ +// get textarea element +var bodyElem = document.querySelector('textarea[name="body"]'); + +// --- Paste handler: convert selection + pasted URL into Markdown link --- +function markdownAutoFormat(event) { + const clipboardData = event.clipboardData || window.clipboardData; + const pastedData = clipboardData.getData('text'); + + const start = bodyElem.selectionStart; + const end = bodyElem.selectionEnd; + + if (start !== end) { + event.preventDefault(); + + const selectedText = bodyElem.value.substring(start, end); + const before = bodyElem.value.substring(0, start); + const after = bodyElem.value.substring(end); + + const markdownLink = `[${selectedText}](${pastedData})`; + + bodyElem.value = before + markdownLink + after; + + const newCursorPosition = before.length + markdownLink.length; + bodyElem.setSelectionRange(newCursorPosition, newCursorPosition); + } +} + +// --- Keyboard shortcuts for formatting --- +function handleKeyDown(event) { + const start = bodyElem.selectionStart; + const end = bodyElem.selectionEnd; + const selectedText = bodyElem.value.substring(start, end); + + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const ctrlKey = isMac ? event.metaKey : event.ctrlKey; + + // --- Inline code: Ctrl/Cmd+Shift+C (Mac-safe) --- + if (ctrlKey && event.shiftKey && event.key.toLowerCase() === 'c') { + event.preventDefault(); + wrapSelection('`', '`'); + return; + } + + // --- Bold: Ctrl/Cmd+B --- + if (ctrlKey && event.key.toLowerCase() === 'b') { + event.preventDefault(); + wrapSelection('**', '**'); + return; + } + + // --- Italics: Ctrl/Cmd+I --- + if (ctrlKey && event.key.toLowerCase() === 'i') { + event.preventDefault(); + wrapSelection('*', '*'); + return; + } + + // --- Strikethrough: Ctrl+Shift+S --- + if (ctrlKey && event.shiftKey && event.key.toLowerCase() === 's') { + event.preventDefault(); + wrapSelection('~~', '~~'); + return; + } + + // --- Headings: Ctrl/Cmd+1..6 --- + if (ctrlKey && !event.shiftKey) { + const headingLevel = parseInt(event.key); + if (headingLevel >= 1 && headingLevel <= 6) { + event.preventDefault(); + insertAtLineStart('#'.repeat(headingLevel) + ' '); + return; + } + } + + // --- Insert link: Ctrl/Cmd+K --- + if (ctrlKey && event.key.toLowerCase() === 'k') { + event.preventDefault(); + const url = prompt('Enter URL:'); + if (url) wrapSelection('[', `](${url})`); + return; + } + + // --- Insert image: Ctrl/Cmd+Shift+L --- + if (ctrlKey && event.shiftKey && event.key.toLowerCase() === 'l') { + event.preventDefault(); + const url = prompt('Enter image URL:'); + if (url) wrapSelection('![', `](${url})`); + return; + } + + // --- Insert footnote: Ctrl/Cmd+Shift+F --- + if (ctrlKey && event.shiftKey && event.key.toLowerCase() === 'f') { + event.preventDefault(); + const footnoteId = prompt('Enter footnote label or number:'); + if (footnoteId) wrapSelection(`[^${footnoteId}]`, ''); + return; + } + + // --- Insert table template: Ctrl/Cmd+Shift+T --- + if (ctrlKey && event.shiftKey && event.key.toLowerCase() === 't') { + event.preventDefault(); + const tableTemplate = '| Header 1 | Header 2 |\n| --- | --- |\n| Cell 1 | Cell 2 |\n'; + insertAtCursor(tableTemplate); + return; + } + + // --- Blockquote / list indentation --- + if (event.key === 'Tab') { + event.preventDefault(); + const lines = getSelectedLines(); + if (event.shiftKey) { + // remove > + replaceLines(lines, line => line.replace(/^>\s?/, '')); + // remove list indentation + replaceLines(lines, line => line.replace(/^ {1,2}/, '')); + } else { + // add > + replaceLines(lines, line => '> ' + line); + // add list indentation + replaceLines(lines, line => ' ' + line); + } + return; + } +} + +// --- Helper to wrap selection with prefix/suffix --- +function wrapSelection(prefix, suffix) { + const start = bodyElem.selectionStart; + const end = bodyElem.selectionEnd; + const selectedText = bodyElem.value.substring(start, end); + + const before = bodyElem.value.substring(0, start); + const after = bodyElem.value.substring(end); + + bodyElem.value = before + prefix + selectedText + suffix + after; + + bodyElem.setSelectionRange( + start + prefix.length, + end + prefix.length + ); +} + +// --- Insert text at cursor (no selection) --- +function insertAtCursor(text) { + const start = bodyElem.selectionStart; + const end = bodyElem.selectionEnd; + + const before = bodyElem.value.substring(0, start); + const after = bodyElem.value.substring(end); + + bodyElem.value = before + text + after; + + const cursorPos = start + text.length; + bodyElem.setSelectionRange(cursorPos, cursorPos); +} + +// --- Insert at start of selected lines --- +function insertAtLineStart(prefix) { + const lines = getSelectedLines(); + replaceLines(lines, line => prefix + line); +} + +// --- Helper to get selected lines --- +function getSelectedLines() { + const start = bodyElem.selectionStart; + const end = bodyElem.selectionEnd; + const value = bodyElem.value; + + const before = value.substring(0, start); + const after = value.substring(end); + + const startLineIndex = before.lastIndexOf('\n') + 1; + const endLineIndex = end + after.indexOf('\n'); + const selectedText = value.substring(startLineIndex, endLineIndex === -1 ? value.length : endLineIndex); + + return { startLineIndex, endLineIndex: endLineIndex === -1 ? value.length : endLineIndex, text: selectedText }; +} + +// --- Helper to replace each line in a selection --- +function replaceLines(linesObj, fn) { + const { startLineIndex, endLineIndex, text } = linesObj; + const before = bodyElem.value.substring(0, startLineIndex); + const after = bodyElem.value.substring(endLineIndex); + + const newText = text.split('\n').map(fn).join('\n'); + bodyElem.value = before + newText + after; + + bodyElem.setSelectionRange(startLineIndex, startLineIndex + newText.length); +} + +// --- Event listeners for Undo/Redo --- +bodyElem.addEventListener('paste', markdownAutoFormat); +bodyElem.addEventListener('keydown', handleKeyDown); + +function wrapSelection(prefix, suffix) { + const start = bodyElem.selectionStart; + const end = bodyElem.selectionEnd; + const selectedText = bodyElem.value.substring(start, end); + + const wrapped = prefix + selectedText + suffix; + + // Use execCommand to keep undo stack + bodyElem.focus(); + bodyElem.setSelectionRange(start, end); + if (document.queryCommandSupported('insertText')) { + document.execCommand('insertText', false, wrapped); + } else { + // fallback + const before = bodyElem.value.substring(0, start); + const after = bodyElem.value.substring(end); + bodyElem.value = before + wrapped + after; + bodyElem.setSelectionRange(start + prefix.length, end + prefix.length); + } +} diff --git a/main/templates/assets/markdown-paste-link.js b/main/templates/assets/markdown-paste-link.js deleted file mode 100644 index cb6f75ee5777eaf6e5317fc071323db6b916bcfe..0000000000000000000000000000000000000000 --- a/main/templates/assets/markdown-paste-link.js +++ /dev/null @@ -1,30 +0,0 @@ -// get body element, used for paste into it -var bodyElem = document.querySelector('textarea[name="body"]'); - -function formatOnPaste(event) { - const clipboardData = event.clipboardData || window.clipboardData; - const pastedData = clipboardData.getData('text'); - - const bodyElem = document.querySelector('textarea[name="body"]'); - - const start = bodyElem.selectionStart; - const end = bodyElem.selectionEnd; - - if (start !== end) { - event.preventDefault(); // Stop the default paste - - const selectedText = bodyElem.value.substring(start, end); - const before = bodyElem.value.substring(0, start); - const after = bodyElem.value.substring(end); - - const markdownLink = `[${selectedText}](${pastedData})`; - - bodyElem.value = before + markdownLink + after; - - // Move cursor after inserted markdown - const newCursorPosition = before.length + markdownLink.length; - bodyElem.setSelectionRange(newCursorPosition, newCursorPosition); - } -} - -bodyElem.addEventListener('paste', formatOnPaste); \ No newline at end of file diff --git a/main/templates/main/page_form.html b/main/templates/main/page_form.html index fd4b76518d2f5d4960e439a51fb780cdaca66444..b65b8aacc1943861322c0703d8c8535f7bac6f1f 100644 --- a/main/templates/main/page_form.html +++ b/main/templates/main/page_form.html @@ -64,8 +64,8 @@ diff --git a/main/templates/main/post_form.html b/main/templates/main/post_form.html index ed463f4752864626e60d3fbc0848b0e16c51161e..79cbf1c9deb18db1108870dbf0c84bbb5abef446 100644 --- a/main/templates/main/post_form.html +++ b/main/templates/main/post_form.html @@ -82,8 +82,8 @@