A main/migrations/0119_remove_user_markdown_link_paste_on_and_more.py => main/migrations/0119_remove_user_markdown_link_paste_on_and_more.py +22 -0
@@ 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'),
+ ),
+ ]
M main/models.py => main/models.py +3 -3
@@ 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
A main/templates/assets/markdown-autoformat.js => main/templates/assets/markdown-autoformat.js +214 -0
@@ 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('`);
+ 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);
+ }
+}
D main/templates/assets/markdown-paste-link.js => main/templates/assets/markdown-paste-link.js +0 -30
@@ 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
M main/templates/main/page_form.html => main/templates/main/page_form.html +2 -2
@@ 64,8 64,8 @@
<script>
// when page loads, focus on title
document.querySelector('input[name="title"]').focus();
- {% if request.user.markdown_link_paste_on %}
- {% include "assets/markdown-paste-link.js" %}
+ {% if request.user.markdown_auto_format_on %}
+ {% include "assets/markdown-autoformat.js" %}
{% endif %}
{% include "assets/drag-and-drop-upload.js" %}
</script>
M main/templates/main/post_form.html => main/templates/main/post_form.html +2 -2
@@ 82,8 82,8 @@
<script>
{% include "assets/drag-and-drop-upload.js" %}
{% include "assets/make-draft-button.js" %}
- {% if request.user.markdown_link_paste_on %}
- {% include "assets/markdown-paste-link.js" %}
+ {% if request.user.markdown_auto_format_on %}
+ {% include "assets/markdown-autoformat.js" %}
{% endif %}
{% if request.user.post_backups_on %}
{% include "assets/save-snapshot.js" %}
M main/tests/test_users.py => main/tests/test_users.py +15 -11
@@ 295,18 295,22 @@ class UserMarkdownLinkOnPaste(TestCase):
self.user = models.User.objects.create(username="alice")
self.client.force_login(self.user)
- def test_markdown_link_turned_on(self):
+ def test_markdown_link_paste_enabled(self):
+ # Enable the feature
self.user.markdown_link_paste_on = True
self.user.save()
- response = self.client.get(
- reverse("post_create"),
- )
- self.assertContains(response, "formatOnPaste")
- def test_markdown_link_turned_off(self):
- self.user.markdown_link_turned_on = False
+ response = self.client.get(reverse("post_create"))
+
+ # The page should include our new JS function name
+ self.assertContains(response, "markdownAutoFormat")
+
+ def test_markdown_link_paste_disabled(self):
+ # Disable the feature
+ self.user.markdown_link_paste_on = False
self.user.save()
- response = self.client.get(
- reverse("post_create"),
- )
- self.assertNotContains(response, "formatOnPaste")>
\ No newline at end of file
+
+ response = self.client.get(reverse("post_create"))
+
+ # The page should NOT include the JS
+ self.assertNotContains(response, "markdownAutoFormat")
M main/views/general.py => main/views/general.py +1 -1
@@ 266,7 266,7 @@ class UserUpdate(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
"comments_on",
"notifications_on",
"mail_export_on",
- "markdown_link_paste_on",
+ "markdown_auto_format_on",
"redirect_domain",
"post_backups_on",
"show_posts_on_homepage",