diff --git a/CHANGES.rst b/CHANGES.rst index f98982f3..40439daa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,8 +8,10 @@ New Features ^^^^^^^^^^^^ - Add option to show/hide website field in comment form (`#1111`_, pkvach) +- Animate comment insertion and deletion (fade-in/out, smooth scroll) (`#1105`_, pkvach) .. _#1111: https://github.com/isso-comments/isso/pull/1111 +.. _#1105: https://github.com/isso-comments/isso/pull/1105 0.14.0 (2026-03-26) -------------------- diff --git a/docs/docs/reference/client-config.rst b/docs/docs/reference/client-config.rst index 762d9e9e..f4b93f45 100644 --- a/docs/docs/reference/client-config.rst +++ b/docs/docs/reference/client-config.rst @@ -272,6 +272,21 @@ data-isso-sorting .. versionadded:: 0.13.1 +.. _data-isso-animations: + +data-isso-animations + Enable or disable animations for comment insertion and deletion. + When enabled, new comments will fade in and deleted comments will fade out. + Animations automatically respect the user's ``prefers-reduced-motion`` setting. + + .. code-block:: html + + + + Default: ``false`` + + .. versionadded:: 0.14.1 + Deprecated Client Settings -------------------------- diff --git a/isso/css/isso.css b/isso/css/isso.css index d208d165..446d1e3b 100644 --- a/isso/css/isso.css +++ b/isso/css/isso.css @@ -34,6 +34,11 @@ --isso-comment-divider-color: rgba(0, 0, 0, 0.1); --isso-page-author-suffix-color: #2c2c2c; --isso-target-fade-background-color: #eee5a1; + + /* Animation variables */ + --isso-animation-duration: 0.3s; + --isso-animation-timing: ease-out; + --isso-comment-max-height: 1000px; } /* ========================================================================== */ @@ -332,6 +337,56 @@ h4.isso-thread-heading { /* Animations */ /* ========================================================================== */ +.isso-comment.isso-anim-initial { + opacity: 0; + transform: translateY(-10px); +} + +/* Animation for new comments appearing */ +@keyframes isso-fade-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Animation for comments being removed */ +@keyframes isso-fade-out { + from { + opacity: 1; + transform: translateY(0); + max-height: var(--isso-comment-max-height); + } + to { + opacity: 0; + transform: translateY(-10px); + max-height: 0; + } +} + +/* Apply fade-in animation to new comments */ +.isso-comment.isso-anim-in { + animation: isso-fade-in var(--isso-animation-duration) var(--isso-animation-timing); +} + +/* Apply fade-out animation to removed comments */ +.isso-comment.isso-anim-out { + animation: isso-fade-out var(--isso-animation-duration) var(--isso-animation-timing); + overflow: hidden; +} + +/* Respect user's motion preferences */ +@media (prefers-reduced-motion: reduce) { + .isso-comment.isso-anim-in, + .isso-comment.isso-anim-out { + animation: none; + } +} + /* "target" means the comment that's being linked to, for example: * https://example.com/blog/example/#isso-15 */ diff --git a/isso/js/app/animations.js b/isso/js/app/animations.js new file mode 100644 index 00000000..66a5d75f --- /dev/null +++ b/isso/js/app/animations.js @@ -0,0 +1,272 @@ +"use strict"; + +var config = require("app/config"); + +// Animation timing constants + +// Default timeout for animation completion fallback in milliseconds. This is used when: +// 1. The computed animation duration cannot be read from the element +// 2. The animation duration is 0s or invalid +var DEFAULT_ANIMATION_TIMEOUT_MS = 500; + +// Additional buffer time added to the computed animation duration to ensure +// the animation completes before the callback is triggered +var ANIMATION_BUFFER_MS = 200; + +// Interval for checking scroll position stability +var SCROLL_CHECK_INTERVAL_MS = 50; + +// Maximum time to wait for scroll completion before forcing callback +var SCROLL_COMPLETION_TIMEOUT_MS = 2000; + +/** + * Unwrap a DOM element if it's wrapped in our Element class + * @param {Element|HTMLElement} element - Either a wrapped Element or raw DOM element + * @returns {HTMLElement} - Raw DOM element + */ +var unwrap = function(element) { + // Check if it's our wrapped Element class (has .obj property) + if (element && typeof element === 'object' && element.obj instanceof window.Element) { + return element.obj; + } + // Already a raw DOM element or null + return element; +}; + +/** + * Check if animations are enabled based on config and user preferences + * @returns {boolean} + */ +var isEnabled = function() { + // Check if animations are disabled in config + if (config["animations"] === false) { + return false; + } + + // Check if user prefers reduced motion + if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return false; + } + + return true; +}; + +/** + * Animate element insertion with fade-in effect + * @param {Element|HTMLElement} element - The DOM element to animate (wrapped or raw) + * @param {boolean} scrollIntoView - Whether to scroll to the element + */ +var animateInsert = function(element, scrollIntoView) { + var rawElement = unwrap(element); + + if (!isEnabled()) { + if (scrollIntoView) { + scrollToElement(rawElement); + } + return; + } + + // Function to add animation class + var addAnimation = function() { + // Remove initial state and add animation class + rawElement.classList.remove('isso-anim-initial'); + rawElement.classList.add('isso-anim-in'); + + // Remove animation class after animation completes + var handleAnimationEnd = function() { + rawElement.classList.remove('isso-anim-in'); + rawElement.removeEventListener('animationend', handleAnimationEnd); + }; + + rawElement.addEventListener('animationend', handleAnimationEnd); + }; + + requestAnimationFrame(function() { + if (scrollIntoView) { + // Wait for scroll to complete before animating + scrollToElement(rawElement, function() { + addAnimation(); + }); + } else { + addAnimation(); + } + }); +}; + +/** + * Prepare and insert element with animation + * This is a convenience function that handles the complete animation workflow: + * 1. Prepares the element for animation (adds initial state) + * 2. Appends element to parent + * 3. Triggers the animation + * + * @param {Element|HTMLElement} element - The DOM element to animate (wrapped or raw) + * @param {Element|HTMLElement} parent - The parent element to append to (wrapped or raw) + * @param {boolean} scrollIntoView - Whether to scroll to the element + */ +var insertWithAnimation = function(element, parent, scrollIntoView) { + var rawElement = unwrap(element); + var rawParent = unwrap(parent); + + if (isEnabled()) { + // Set initial state before element is visible + rawElement.classList.add('isso-anim-initial'); + } + + // Append to parent + rawParent.appendChild(rawElement); + + // Trigger animation + animateInsert(rawElement, scrollIntoView); +}; + +/** + * Animate element removal with fade-out effect + * @param {Element|HTMLElement} element - The DOM element to animate (wrapped or raw) + * @param {Function} callback - Function to call after animation completes + */ +var animateRemove = function(element, callback) { + var rawElement = unwrap(element); + + if (!isEnabled()) { + if (callback) { + callback(); + } + return; + } + + // Add animation class + rawElement.classList.add('isso-anim-out'); + + var completed = false; + var timeoutId = null; + + // Wait for animation to complete before removing + var handleAnimationEnd = function() { + if (completed) return; + completed = true; + + // Clear the fallback timeout + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + + rawElement.removeEventListener('animationend', handleAnimationEnd); + if (callback) { + callback(); + } + }; + + rawElement.addEventListener('animationend', handleAnimationEnd); + + // Get animation duration from computed style for fallback timeout + var fallbackTimeout = DEFAULT_ANIMATION_TIMEOUT_MS; + try { + var style = window.getComputedStyle(rawElement); + var durationStr = style.animationDuration; + if (durationStr && durationStr !== '0s') { + // Parse duration (could be in 's' or 'ms') + var duration = parseFloat(durationStr); + // Validate that duration is a valid number + if (!isNaN(duration) && duration > 0) { + fallbackTimeout = durationStr.includes('ms') ? duration : duration * 1000; + // Add buffer to ensure animation completes + fallbackTimeout += ANIMATION_BUFFER_MS; + } + } + } catch (e) { + // If we can't read the style, use default fallback timeout + } + + // Fallback timeout in case animationend doesn't fire + timeoutId = setTimeout(function() { + handleAnimationEnd(); + }, fallbackTimeout); +}; + +/** + * Creates a self-cancelling scroll completion watcher. + * Polls window.scrollY every 50ms and fires the callback once + * the position has been stable for one tick, or after a 2s safety timeout. + * + * @param {Function} callback - Called once when scrolling is deemed complete + * @returns {void} + */ +var watchScrollCompletion = function(callback) { + var called = false; + var lastScrollY = window.scrollY; + var scrollCheckInterval = null; + var fallbackTimeout = null; + + // Single exit point — clears both the interval and timeout + // before invoking the callback, preventing double invocation + var done = function() { + if (called) return; + called = true; + if (scrollCheckInterval !== null) { + clearInterval(scrollCheckInterval); + } + if (fallbackTimeout !== null) { + clearTimeout(fallbackTimeout); + } + callback(); + }; + + // Poll at regular intervals — the initial delay naturally ensures the first + // check happens after scrollIntoView has had a chance to begin moving + scrollCheckInterval = setInterval(function() { + if (window.scrollY === lastScrollY) { + done(); + } + lastScrollY = window.scrollY; + }, SCROLL_CHECK_INTERVAL_MS); + + // Safety net: if scroll detection stalls (e.g. very long page, + // slow device, or reduced-motion override), force-complete after timeout + fallbackTimeout = setTimeout(done, SCROLL_COMPLETION_TIMEOUT_MS); +}; + +/** + * Smooth scrolls to a given element with an optional post-scroll callback. + * Falls back to instant scroll on browsers that don't support smooth behavior. + * + * @param {Element|HTMLElement} element - The target DOM element to scroll to (wrapped or raw) + * @param {Function} callback - Optional. Called after scroll completes (or immediately on fallback) + */ +var scrollToElement = function(element, callback) { + var rawElement = unwrap(element); + + if (!rawElement) { + return; + } + + // Smooth scroll path — supported in all modern browsers + if ('scrollBehavior' in document.documentElement.style) { + try { + rawElement.scrollIntoView({ behavior: 'smooth' }); + + // Only watch for completion if a callback was provided + if (callback) { + watchScrollCompletion(callback); + } + return; + } catch (e) { + // scrollIntoView with options not supported — fall through to basic scroll + } + } + + // Fallback: instant scroll, no completion detection needed + rawElement.scrollIntoView(); + + // Instant scroll, so callback can be called immediately + if (callback) { + requestAnimationFrame(callback); + } +}; + +module.exports = { + isEnabled: isEnabled, + insertWithAnimation: insertWithAnimation, + animateRemove: animateRemove, + scrollToElement: scrollToElement +}; \ No newline at end of file diff --git a/isso/js/app/default_config.js b/isso/js/app/default_config.js index 6a872aed..f4870a92 100644 --- a/isso/js/app/default_config.js +++ b/isso/js/app/default_config.js @@ -23,6 +23,7 @@ var default_config = { "vote-levels": null, "feed": false, "page-author-hashes": "", + "animations": false, }; Object.freeze(default_config); diff --git a/isso/js/app/isso.js b/isso/js/app/isso.js index 5471c394..79c9724f 100644 --- a/isso/js/app/isso.js +++ b/isso/js/app/isso.js @@ -8,6 +8,7 @@ var template = require("app/template"); var i18n = require("app/i18n"); var identicons = require("app/lib/identicons"); var globals = require("app/globals"); +var animations = require("app/animations"); "use strict"; @@ -120,7 +121,12 @@ var Postbox = function(parent) { }).then( function(comment) { $(".isso-textarea", el).value = ""; - insert({ comment, scrollIntoView: true, offset: 0 }); + insert({ + comment, + scrollIntoView: true, + offset: 0, + animate: true // Animate new comment insertion + }); if (parent !== null) { el.onsuccess(); @@ -172,8 +178,12 @@ var insert_loader = function(comment, offset) { } rv.replies.forEach(function(commentObject) { - insert({ comment: commentObject, scrollIntoView: false, offset: 0 }); - + insert({ + comment: commentObject, + scrollIntoView: false, + offset: 0, + animate: true // Animate when revealing hidden comments + }); }); if(rv.hidden_replies > 0) { @@ -187,7 +197,7 @@ var insert_loader = function(comment, offset) { }); }; -var insert = function({ comment, scrollIntoView, offset }) { +var insert = function({ comment, scrollIntoView, offset, animate = false }) { var el = $.htmlify(template.render("comment", {"comment": comment})); // update datetime every 60 seconds @@ -211,10 +221,14 @@ var insert = function({ comment, scrollIntoView, offset }) { entrypoint = $("#isso-" + comment.parent + " > .isso-follow-up"); } - entrypoint.append(el); + if (animate) { + animations.insertWithAnimation(el, entrypoint, scrollIntoView); + } else { + entrypoint.append(el); - if (scrollIntoView) { - el.scrollIntoView(); + if (scrollIntoView) { + animations.scrollToElement(el); + } } var footer = $("#isso-" + comment.id + " > .isso-text-wrapper > .isso-comment-footer"), @@ -362,7 +376,10 @@ var insert = function({ comment, scrollIntoView, offset }) { var del = $("a.isso-delete", footer); api.remove(comment.id).then(function(rv) { if (rv) { - el.remove(); + // Remove comment from DOM (with animation if enabled) + animations.animateRemove(el, function() { + el.remove(); + }); } else { $("span.isso-note", header).textContent = i18n.translate("comment-deleted"); text.innerHTML = "

 

"; diff --git a/isso/js/tests/unit/animations.test.js b/isso/js/tests/unit/animations.test.js new file mode 100644 index 00000000..81dea8c8 --- /dev/null +++ b/isso/js/tests/unit/animations.test.js @@ -0,0 +1,343 @@ +/** + * @jest-environment jsdom + */ + +describe('Animation Module', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + + // Mock matchMedia + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + // Mock requestAnimationFrame + global.requestAnimationFrame = jest.fn((cb) => { + cb(); + return 1; + }); + + // Mock scrollY + Object.defineProperty(window, 'scrollY', { + writable: true, + value: 0 + }); + + // Mock scrollBehavior support + Object.defineProperty(document.documentElement.style, 'scrollBehavior', { + writable: true, + value: '' + }); + + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + describe('with animations enabled', () => { + let animations; + + beforeEach(() => { + jest.resetModules(); + jest.doMock("app/config", () => ({ + animations: true + })); + animations = require("app/animations"); + }); + + test('isEnabled returns true when animations are enabled', () => { + expect(animations.isEnabled()).toBe(true); + }); + + test('isEnabled respects prefers-reduced-motion', () => { + window.matchMedia = jest.fn().mockImplementation(query => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + })); + + expect(animations.isEnabled()).toBe(false); + }); + + test('insertWithAnimation adds element to parent with animation', () => { + const element = document.getElementById('test-element'); + const parent = document.getElementById('parent-element'); + + // Remove element from DOM first + element.remove(); + + animations.insertWithAnimation(element, parent, false); + + // Check element was appended to parent + expect(parent.contains(element)).toBe(true); + + // Check initial animation class was added + expect(element.classList.contains('isso-anim-initial')).toBe(true); + + // Advance timers to trigger requestAnimationFrame + jest.runAllTimers(); + + // Check animation class was added + expect(element.classList.contains('isso-anim-in')).toBe(true); + }); + + test('insertWithAnimation handles scrollIntoView', () => { + const element = document.getElementById('test-element'); + const parent = document.getElementById('parent-element'); + + element.remove(); + element.scrollIntoView = jest.fn(); + + animations.insertWithAnimation(element, parent, true); + + // Advance timers to trigger requestAnimationFrame and scroll + jest.runAllTimers(); + + expect(element.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }); + }); + + test('insertWithAnimation removes animation classes after completion', () => { + const element = document.getElementById('test-element'); + const parent = document.getElementById('parent-element'); + + element.remove(); + + animations.insertWithAnimation(element, parent, false); + + // Advance timers to trigger animation + jest.runAllTimers(); + + expect(element.classList.contains('isso-anim-in')).toBe(true); + + // Simulate animationend event + const event = new Event('animationend'); + element.dispatchEvent(event); + + expect(element.classList.contains('isso-anim-in')).toBe(false); + }); + + test('insertWithAnimation with scroll waits for scroll completion before animating', () => { + const element = document.getElementById('test-element'); + const parent = document.getElementById('parent-element'); + + element.remove(); + element.scrollIntoView = jest.fn(); + + animations.insertWithAnimation(element, parent, true); + + // Element should be in parent and have initial class + expect(parent.contains(element)).toBe(true); + expect(element.classList.contains('isso-anim-initial')).toBe(true); + + // Advance to trigger requestAnimationFrame + jest.runAllTimers(); + + // Should have called scrollIntoView + expect(element.scrollIntoView).toHaveBeenCalled(); + + // Animation class should be added after scroll completion + expect(element.classList.contains('isso-anim-in')).toBe(true); + }); + + test('animateRemove adds animation class and calls callback', () => { + const element = document.getElementById('test-element'); + const callback = jest.fn(); + + animations.animateRemove(element, callback); + + expect(element.classList.contains('isso-anim-out')).toBe(true); + + // Simulate animationend event + const event = new Event('animationend'); + element.dispatchEvent(event); + + expect(callback).toHaveBeenCalled(); + }); + + test('animateRemove has fallback timeout', () => { + const element = document.getElementById('test-element'); + const callback = jest.fn(); + + animations.animateRemove(element, callback); + + expect(element.classList.contains('isso-anim-out')).toBe(true); + + // Don't fire animationend, just advance timers + jest.advanceTimersByTime(500); + + expect(callback).toHaveBeenCalled(); + }); + + test('animateRemove prevents double callback execution', () => { + const element = document.getElementById('test-element'); + const callback = jest.fn(); + + animations.animateRemove(element, callback); + + // Fire animationend event + const event = new Event('animationend'); + element.dispatchEvent(event); + + // Also trigger timeout + jest.advanceTimersByTime(500); + + // Callback should only be called once + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('scrollToElement uses smooth scrollIntoView when available', () => { + const element = document.getElementById('test-element'); + element.scrollIntoView = jest.fn(); + + animations.scrollToElement(element); + + expect(element.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth' + }); + }); + + test('scrollToElement calls callback after scroll completion', () => { + const element = document.getElementById('test-element'); + const callback = jest.fn(); + element.scrollIntoView = jest.fn(); + + animations.scrollToElement(element, callback); + + expect(element.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth' + }); + + // Simulate scroll completion by advancing timers + // First tick is skipped, second tick detects stable position + jest.advanceTimersByTime(50); // First tick + jest.advanceTimersByTime(50); // Second tick - should detect stable + + expect(callback).toHaveBeenCalled(); + }); + + test('scrollToElement callback has safety timeout', () => { + const element = document.getElementById('test-element'); + const callback = jest.fn(); + element.scrollIntoView = jest.fn(); + + // Simulate continuous scrolling by changing scrollY + let mockScrollYCounter = 0; + Object.defineProperty(window, 'scrollY', { + get: () => mockScrollYCounter++, + configurable: true + }); + + animations.scrollToElement(element, callback); + + // Advance to safety timeout + jest.advanceTimersByTime(2000); + + expect(callback).toHaveBeenCalled(); + }); + + test('scrollToElement falls back when scrollBehavior is not supported', () => { + const element = document.getElementById('test-element'); + const callback = jest.fn(); + + // Create a new style object without scrollBehavior + const mockStyle = {}; + Object.defineProperty(document.documentElement, 'style', { + configurable: true, + value: mockStyle + }); + + element.scrollIntoView = jest.fn(); + + animations.scrollToElement(element, callback); + + // Should call basic scrollIntoView without options + expect(element.scrollIntoView).toHaveBeenCalledWith(); + + // Callback should be called immediately + jest.runAllTimers(); + expect(callback).toHaveBeenCalled(); + }); + + test('scrollToElement handles scrollIntoView exception', () => { + const element = document.getElementById('test-element'); + const callback = jest.fn(); + + // Mock to throw only when called with options (smooth scroll) + // but succeed when called without options (fallback) + element.scrollIntoView = jest.fn((options) => { + if (options && options.behavior === 'smooth') { + throw new Error('Not supported'); + } + // Fallback call without options succeeds silently + }); + + animations.scrollToElement(element, callback); + + // Should have been called twice: once with options (threw), once without (succeeded) + expect(element.scrollIntoView).toHaveBeenCalledTimes(2); + expect(element.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }); + expect(element.scrollIntoView).toHaveBeenCalledWith(); + + // Callback should be called immediately after fallback + jest.runAllTimers(); + expect(callback).toHaveBeenCalled(); + }); + + test('scrollToElement handles null element gracefully', () => { + expect(() => { + animations.scrollToElement(null); + }).not.toThrow(); + }); + }); + + describe('with animations disabled', () => { + let animations; + + beforeEach(() => { + jest.resetModules(); + jest.doMock("app/config", () => ({ + animations: false + })); + animations = require("app/animations"); + }); + + test('isEnabled returns false when config.animations is false', () => { + expect(animations.isEnabled()).toBe(false); + }); + + test('insertWithAnimation skips animation when disabled', () => { + const element = document.getElementById('test-element'); + const parent = document.getElementById('parent-element'); + + element.remove(); + + animations.insertWithAnimation(element, parent, false); + + expect(parent.contains(element)).toBe(true); + expect(element.classList.contains('isso-anim-initial')).toBe(false); + expect(element.classList.contains('isso-anim-in')).toBe(false); + }); + + test('animateRemove calls callback immediately when disabled', () => { + const element = document.getElementById('test-element'); + const callback = jest.fn(); + + animations.animateRemove(element, callback); + + expect(element.classList.contains('isso-anim-out')).toBe(false); + expect(callback).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file