Remove jQuery, convert to vanilla JS, add textarea auto-grow

- Remove jQuery (84KB) - all functionality now vanilla JS
- Remove legacy google-analytics.j2 (using gtag v4 instead)
- Remove ie8.polyfils.min.js (IE8 is dead)
- Add X-Requested-With header for AJAX preview requests
- Textareas auto-grow up to 400px as content is added
- Auto-grow triggers on toggle open if textarea has content
This commit is contained in:
Russell Ballestrini 2025-12-19 19:54:53 -05:00
parent ef8f382976
commit 6e624f392c
7 changed files with 311 additions and 96 deletions

197
docs/JAVASCRIPT.rst Normal file
View file

@ -0,0 +1,197 @@
JavaScript Usage in Remarkbox
=============================
This document catalogs all JavaScript usage in the Remarkbox codebase.
Standalone JavaScript Files
---------------------------
remarkbox/static/js/custom.js
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Main application JavaScript containing core functionality.
**previewAjax()** (Lines 5-20)
Debounced preview function with 800ms timer. Escapes HTML in raw mode
to prevent XSS, then calls sendPreview().
**sendPreview()** (Lines 22-38)
AJAX request to ``/preview-post`` endpoint for Markdown rendering.
Optionally triggers MathJax re-rendering.
**toggle()** (Lines 41-57)
CSS-based toggle animation. Adds/removes ``toggle-open`` and
``toggle-closing`` classes. Updates button text after 800ms animation.
**Details close animation** (Lines 60-75)
Event listener for ``.preview-toggle`` clicks. Animates ``<details>``
element closure over 800ms using ``closing`` class.
**Document ready handler** (Lines 77-103)
- Binds vote-up/vote-down button click handlers
- Fades in alert elements over 2 seconds
- Highlights URL fragment targets with ``focused`` class
**sendVote()** (Lines 105-118)
AJAX request to ``/vote-post`` endpoint. Updates vote count on success.
remarkbox/static/js/jquery-2.1.3.min.js
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
jQuery library for DOM manipulation and AJAX.
remarkbox/static/js/iframe-resizer/
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
External library for responsive iframe sizing in embed mode.
- ``iframeResizer.min.js`` - Main resizer script
- ``iframeResizer.contentWindow.min.js`` - Content window script
Inline JavaScript in Templates
------------------------------
Form Submission Protection
~~~~~~~~~~~~~~~~~~~~~~~~~~
Pattern: ``onsubmit="submit.disabled = true; return true;"``
Disables submit button to prevent double submission. Used in:
- ``snippets/forms.j2`` - Reply, edit, pay-what-you-can forms
- ``snippets/create.j2`` - Thread creation form
- ``snippets/snippets.j2`` - Watch, unwatch, lock, unlock, disable, enable, verify, approve, deny forms
- ``snippets/search.j2`` - Search form
- ``join-or-log-in.j2`` - Login form
- ``setup-namespace.j2`` - Namespace setup/cancel forms
- ``namespace-settings.j2`` - Settings forms
- ``user-settings.j2`` - User settings form
- ``user-watching.j2`` - Watching management form
Live Markdown Preview
~~~~~~~~~~~~~~~~~~~~~
Pattern: ``onkeyup="previewAjax(...)"``
Triggers debounced Markdown preview on textarea input.
**snippets/forms.j2** (Line 21)
Reply textarea with raw preview::
previewAjax('textarea-{{ node.id }}', 'preview-{{ node.id }}', show_raw=true, mathjax={{ request.mathjax }})
**snippets/forms.j2** (Line 63)
Edit textarea without raw preview::
previewAjax('edit-textarea-{{ node.id }}', 'node-data-{{ node.id }}', show_raw=false, mathjax={{ request.mathjax }})
**snippets/create.j2** (Line 14)
Thread creation textarea::
previewAjax('thread_data_textarea', 'preview', show_raw=true, mathjax={{ request.mathjax }})
Toggle Functionality
~~~~~~~~~~~~~~~~~~~~
**base.j2** (Line 38)
Namespace switcher menu::
onclick="toggle('my-namespaces-div', 'my-namespaces-link', '(switch)', '(switch)'); return false;"
**snippets/snippets.j2** (Line 88)
Remark button - shows reply form and focuses textarea::
onclick="toggle('remark-box-{{ node.id }}', 'remark-link-{{ node.id }}', 'remark', 'hide'); document.getElementById('textarea-{{ node.id }}').focus(); return false;"
**snippets/snippets.j2** (Line 103)
Collapse button - hides/shows child nodes::
onclick="toggle('node-children-{{ node.id }}', 'collapse-link-{{ node.id }}', 'expand [+]', 'collapse [-]');"
**snippets/snippets.j2** (Line 214)
Edit button - shows edit form and focuses textarea::
onclick="toggle('edit-box-{{ node.id }}', 'edit-link-{{ node.id }}', 'edit', 'hide'); document.getElementById('edit-textarea-{{ node.id }}').focus(); return false;"
Alert Dismissal
~~~~~~~~~~~~~~~
**snippets/flash-alerts.j2** (Line 4)
Click to dismiss alert::
onclick="this.style.display='none'"
Theme Preview
~~~~~~~~~~~~~
**user-settings.j2** (Lines 56-60)
Radio buttons for theme mode::
onchange="previewTheme(this.value)"
**user-settings.j2** (Lines 108-127)
Theme preview function - applies ``dark-mode`` class to HTML element.
External Scripts
----------------
snippets/javascript-includes.j2
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**CSRF Token** (Line 6)
Global variable for AJAX requests::
var csrf_token = "{{ request.session.get_csrf_token() }}";
**Google Analytics v4** (Lines 12-18)
Conditional loading based on namespace configuration.
**MathJax** (Lines 25-27)
Mathematical formula rendering. Loaded from CDN when enabled.
embed-iframe.txt.j2
~~~~~~~~~~~~~~~~~~~
Embed script (Lines 8-42) that:
1. Captures parent page URL, title, and fragment
2. Creates Remarkbox iframe with configuration
3. Initializes iframe-resizer for responsive sizing
snippets/stripe.j2
~~~~~~~~~~~~~~~~~~
**Stripe v3** (Line 57)
Payment processing library from ``https://js.stripe.com/v3/``
**Payment form handling** (Lines 83-132)
Stripe card element initialization, validation, and token creation.
snippets/google-analytics.j2
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Legacy Universal Analytics (ga.js) for backward compatibility.
CSS Classes Managed by JavaScript
---------------------------------
- ``toggle-open`` - Element is visible with open animation
- ``toggle-closing`` - Element is animating closed
- ``closing`` - Details element is animating closed
- ``focused`` - URL fragment target highlighting
- ``dark-mode`` - Dark theme applied to HTML element
No-JavaScript Fallback
----------------------
Remarkbox functions without JavaScript:
- Toggle links have ``href`` attributes pointing to dedicated pages
(e.g., ``/{node_id}/edit``, ``/{node_id}/reply``)
- Forms submit normally without AJAX
- ``<details>`` elements work natively for preview toggle
- Voting requires JavaScript (AJAX-only)

View file

@ -552,9 +552,10 @@ form.node-action {
}
.common-textarea {
min-height: calc(2rem * var(--line-height));
height: calc(5rem * var(--line-height));
min-height: calc(3rem * var(--line-height));
max-height: 400px;
resize: vertical;
overflow-y: auto;
}
.monospace {

View file

@ -2,43 +2,48 @@
// previewTimer must live outside the functions.
var previewTimer = null;
function previewAjax(textarea, div, show_raw = false, mathjax = false){
function previewAjax(textarea, div, show_raw, mathjax) {
// set div to raw textarea while waiting for remote Markdown rendering.
if (show_raw) {
// bust HTML tags like <script> to prevent running evil code.
var busted_textarea = $('#'+textarea).val().replace(/&/g, '&amp;').replace(/</g,'&lt;');
// $('#' + div).html('<div class="preview-raw-markdown">'+busted_textarea+'</div>');
$('#' + div).html('<span class="preview-raw-markdown">'+busted_textarea+'</span>');
// bust HTML tags like <script> to prevent running evil code.
var el = document.getElementById(textarea);
var busted_textarea = el.value.replace(/&/g, '&amp;').replace(/</g, '&lt;');
document.getElementById(div).innerHTML = '<span class="preview-raw-markdown">' + busted_textarea + '</span>';
}
if (previewTimer) {
clearTimeout(previewTimer);
}
previewTimer = setTimeout(
function() { sendPreview( textarea, div, mathjax ); },
800
function() { sendPreview(textarea, div, mathjax); },
800
);
}
function sendPreview(textarea, div, mathjax=false){
$.ajaxSetup ({
cache: false
function sendPreview(textarea, div, mathjax) {
var url = '/preview-post';
var data = new FormData();
data.append('data', document.getElementById(textarea).value);
data.append('csrf_token', csrf_token);
fetch(url, {
method: 'POST',
body: data,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(response) { return response.text(); })
.then(function(html) {
document.getElementById(div).innerHTML = html;
if (mathjax && typeof MathJax !== 'undefined') {
setTimeout(function() {
MathJax.Hub.Queue(["Typeset", MathJax.Hub, div]);
}, 100);
}
});
var url = '/preview-post';
var params = { 'data' : $('#'+textarea).val(), 'csrf_token' : csrf_token };
$( '#' + div ).load( url, params );
if (mathjax) {
setTimeout(function(){
// hack: block MathJax rerender for 50ms, give div chance to load.
MathJax.Hub.Queue(["Typeset",MathJax.Hub,div]);
}, 100);
}
}
// CSS-based toggle for smoother animations.
function toggle(target, button, off_text, on_text="hide"){
function toggle(target, button, off_text, on_text) {
if (typeof on_text === 'undefined') on_text = 'hide';
var el = document.getElementById(target);
var btn = document.getElementById(button);
if (el.classList.contains('toggle-open')) {
@ -53,66 +58,100 @@ function toggle(target, button, off_text, on_text="hide"){
// Update text immediately when opening
btn.textContent = on_text;
el.classList.add('toggle-open');
// Auto-grow any textareas that have content
var textarea = el.querySelector('.common-textarea');
if (textarea && textarea.value) {
setTimeout(function() { autoGrow(textarea); }, 10);
}
}
}
// Animate <details> close for preview-details elements.
if (typeof document.addEventListener === 'function') {
document.addEventListener('click', function(e) {
var summary = e.target.closest('.preview-toggle');
if (!summary) return;
var details = summary.parentElement;
if (!details || !details.classList.contains('preview-details')) return;
if (details.open && !details.classList.contains('closing')) {
e.preventDefault();
details.classList.add('closing');
setTimeout(function() {
details.open = false;
details.classList.remove('closing');
}, 800);
}
}, true);
document.addEventListener('click', function(e) {
var summary = e.target.closest('.preview-toggle');
if (!summary) return;
var details = summary.parentElement;
if (!details || !details.classList.contains('preview-details')) return;
if (details.open && !details.classList.contains('closing')) {
e.preventDefault();
details.classList.add('closing');
setTimeout(function() {
details.open = false;
details.classList.remove('closing');
}, 800);
}
}, true);
}
$(document).ready( function() {
// Auto-grow textarea as content is added
function autoGrow(el) {
el.style.height = 'auto';
var newHeight = Math.min(el.scrollHeight, 400);
el.style.height = newHeight + 'px';
}
$('button.vote-up').click(
function(){
var post_id = $(this).closest('div').attr('id');
sendVote( post_id, 'up' );
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
// Auto-grow textareas on input
document.querySelectorAll('.common-textarea').forEach(function(textarea) {
textarea.addEventListener('input', function() {
autoGrow(this);
});
});
// Vote button handlers
document.querySelectorAll('button.vote-up').forEach(function(btn) {
btn.addEventListener('click', function() {
var post_id = this.closest('div').id;
sendVote(post_id, 'up');
});
});
document.querySelectorAll('button.vote-down').forEach(function(btn) {
btn.addEventListener('click', function() {
var post_id = this.closest('div').id;
sendVote(post_id, 'down');
});
});
// Fade in alert elements
document.querySelectorAll('.alert').forEach(function(el) {
setTimeout(function() {
el.style.transition = 'opacity 2s';
el.style.opacity = '1';
}, 100);
});
// Highlight fragment hash div if it exists
if (window.location.hash) {
var fragment = document.getElementById('node-data-' + window.location.hash.replace('#', ''));
if (fragment) {
fragment.classList.add('focused');
}
}
);
$('button.vote-down').click(
function(){
var post_id = $(this).closest('div').attr('id');
sendVote( post_id, 'down' );
}
);
// make all elements with class alert fade into existance.
$('.alert').delay(100).animate({"opacity": "1"}, 2000);
// highlight a fragment hash div if it exsits.
if(window.location.hash) {
// Fragment exists, so target the node's div.
var fragment = '#node-data-' + window.location.hash.replace('#', '');
$(fragment).addClass("focused");
}
});
function sendVote( post_id, direction ){
var url = '/vote-post';
var params = { 'post_id' : post_id, 'direction' : direction, 'csrf_token' : csrf_token };
function sendVote(post_id, direction) {
var url = '/vote-post';
var params = new URLSearchParams({
'post_id': post_id,
'direction': direction,
'csrf_token': csrf_token
});
$.getJSON( url, params, function( r ){
if ( r['status'] ) {
$('#'+post_id).children('b').html(r['vote_sum']);
}
else {
$('#json').html(r['msg']);
}
}
);
fetch(url + '?' + params.toString())
.then(function(response) { return response.json(); })
.then(function(r) {
if (r['status']) {
var el = document.getElementById(post_id);
var b = el.querySelector('b');
if (b) b.innerHTML = r['vote_sum'];
} else {
var jsonEl = document.getElementById('json');
if (jsonEl) jsonEl.innerHTML = r['msg'];
}
});
}

View file

@ -1,4 +0,0 @@
// IE8 polyfils for iframeResizer.js
Array.prototype.forEach||(Array.prototype.forEach=function(a){"use strict";if(void 0===this||null===this||"function"!=typeof a)throw new TypeError;for(var b=Object(this),c=b.length>>>0,d=arguments.length>=2?arguments[1]:void 0,e=0;c>e;e++)e in b&&a.call(d,b[e],e,b)}),Function.prototype.bind||(Function.prototype.bind=function(a){if("function"!=typeof this)throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");var b=Array.prototype.slice.call(arguments,1),c=this,d=function(){},e=function(){return c.apply(this instanceof d?this:a,b.concat(Array.prototype.slice.call(arguments)))};return d.prototype=this.prototype,e.prototype=new d,e}),Array.prototype.forEach||(Array.prototype.forEach=function(a,b){if(null===this)throw new TypeError(" this is null or not defined");if("function"!=typeof a)throw new TypeError(a+" is not a function");for(var c=Object(this),d=c.length>>>0,e=0;d>e;e++)e in c&&a.call(b,c[e],e,c)});
//# sourceMappingURL=ie8.polyfils.map

File diff suppressed because one or more lines are too long

View file

@ -1,12 +0,0 @@
{% if 'google-analytics-id' in request.app %}
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', '{{ request.app['google-analytics-id'] }}', 'auto');
ga('send', 'pageview');
</script>
{% endif %}

View file

@ -5,7 +5,6 @@
<!-- this is needed for async javascript requests -->
<script>var csrf_token = "{{ request.session.get_csrf_token() | default("none", true) }}";</script>
<script src="/static/js/jquery-2.1.3.min.js"></script>
<script src="/static/js/iframe-resizer/iframeResizer.contentWindow.min.js"></script>
<script src="/static/js/custom.js"></script>
{%- if request.mode == "basic" and request.namespace and request.namespace.name == request.domain and request.namespace.google_analytics_id %}
@ -26,4 +25,3 @@
src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-MML-AM_CHTML,Safe">
</script>
{%- endif %}
{%- include 'google-analytics.j2' %}