~linuxgoose/bocpress

94845349eca95c8f20fc0a83adfcf322de7ebf53 — Jordan Robinson 2 months ago a9d67bd
update markdown auto formatter to include many more keyboard shortcuts
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('![', `](${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);
    }
}

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",