User:Polygnotus/Scripts/Timeline.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | Documentation for this user script can be added at User:Polygnotus/Scripts/Timeline. |
(function() {
'use strict';
// Only run on discussion pages
if (!mw.config.get('wgIsArticle') || !document.querySelector('.ext-discussiontools-init-section')) {
return;
}
// Extract comment data from a specific section
function extractCommentsFromSection(section) {
const comments = [];
// Determine the level of this section
const sectionLevel = section.className.match(/mw-heading(\d)/)?.[1] || '2';
// Find all comment markers in this section
let currentElement = section.nextElementSibling;
while (currentElement) {
// Only stop if we hit a section of the same or higher level
const headingMatch = currentElement.className?.match(/mw-heading(\d)/);
if (headingMatch) {
const currentLevel = headingMatch[1];
if (parseInt(currentLevel) <= parseInt(sectionLevel)) {
break;
}
}
// Find all comment start markers
const commentStarts = currentElement.querySelectorAll('[data-mw-comment-start]');
commentStarts.forEach(startMarker => {
// Find the complete comment by looking for all elements between start and the signature
const commentElements = [];
let searchElement = startMarker.closest('p, dd, div');
if (!searchElement) {
searchElement = startMarker.parentElement;
}
// Keep collecting elements until we find the timestamp/signature
let foundTimestamp = false;
let tempElement = searchElement;
let author = 'Unknown';
let timestampLink = null;
let timestampText = '';
while (tempElement && !foundTimestamp) {
commentElements.push(tempElement);
// Check if this element contains the timestamp
const timestamp = tempElement.querySelector('.ext-discussiontools-init-timestamplink');
if (timestamp) {
timestampLink = timestamp;
timestampText = timestamp.textContent;
foundTimestamp = true;
// Look for author in the same element
const authorLink = tempElement.querySelector('a[href*="User:"]');
if (authorLink) {
author = authorLink.textContent;
}
}
// Move to next sibling
tempElement = tempElement.nextElementSibling;
}
// Only create a comment if we found both start and timestamp
if (timestampLink && commentElements.length > 0) {
// Parse timestamp
const timestampMatch = timestampText.match(/(\d{1,2}):(\d{2}), (\d{1,2}) (\w+) (\d{4})/);
if (timestampMatch) {
const [, hours, minutes, day, month, year] = timestampMatch;
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const monthIndex = monthNames.indexOf(month);
const timestamp = new Date(year, monthIndex, day, hours, minutes);
comments.push({
id: startMarker.id,
elements: commentElements,
author: author,
timestamp: timestamp,
timestampText: timestampText
});
}
}
});
currentElement = currentElement.nextElementSibling;
}
return comments.sort((a, b) => a.timestamp - b.timestamp);
}
// Create timeline slider UI for a specific section
function createTimelineSlider(section) {
const comments = extractCommentsFromSection(section);
if (comments.length < 10) return; // Only add slider for sections with 10+ comments
// Store original display values to restore them properly
const originalDisplayValues = new Map();
comments.forEach(comment => {
comment.elements.forEach(element => {
originalDisplayValues.set(element, element.style.display || '');
});
});
const sliderContainer = document.createElement('div');
sliderContainer.style.cssText = `
margin: 10px 0;
padding: 10px;
background-color: #f8f9fa;
border: 1px solid #a2a9b1;
border-radius: 2px;
`;
const sliderLabel = document.createElement('div');
sliderLabel.style.cssText = `
font-weight: bold;
margin-bottom: 5px;
`;
sliderLabel.textContent = 'Timeline: ';
const dateDisplay = document.createElement('span');
dateDisplay.style.cssText = `
font-weight: normal;
margin-left: 5px;
`;
sliderLabel.appendChild(dateDisplay);
// Comment counter display
const commentCounter = document.createElement('div');
commentCounter.style.cssText = `
font-size: 0.9em;
color: #555;
margin-bottom: 5px;
`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = comments.length - 1;
slider.value = comments.length - 1; // Default to showing all comments
slider.style.cssText = `
width: 100%;
margin: 10px 0;
`;
// Step forward/backward buttons
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 10px;
align-items: center;
margin-top: 10px;
`;
const prevButton = document.createElement('button');
prevButton.textContent = '← Previous';
prevButton.style.cssText = `
padding: 5px 10px;
background-color: #36c;
color: white;
border: none;
border-radius: 2px;
cursor: pointer;
`;
const nextButton = document.createElement('button');
nextButton.textContent = 'Next →';
nextButton.style.cssText = `
padding: 5px 10px;
background-color: #36c;
color: white;
border: none;
border-radius: 2px;
cursor: pointer;
`;
const resetButton = document.createElement('button');
resetButton.textContent = 'Show All';
resetButton.style.cssText = `
padding: 5px 10px;
background-color: #36c;
color: white;
border: none;
border-radius: 2px;
cursor: pointer;
margin-left: auto;
`;
// Flag to prevent scrolling on initial load
let isInitialLoad = true;
let updateInProgress = false;
let pendingUpdate = null;
// Function to update visible comments for this section only
function updateVisibleComments(index) {
// Prevent concurrent updates
if (updateInProgress) {
pendingUpdate = index;
return;
}
updateInProgress = true;
try {
// Validate index
index = Math.max(0, Math.min(index, comments.length - 1));
// Update displays
dateDisplay.textContent = `${comments[index].timestampText} by ${comments[index].author}`;
commentCounter.textContent = `Showing comments 1-${index + 1} of ${comments.length}`;
// Update button states
prevButton.disabled = index === 0;
nextButton.disabled = index === comments.length - 1;
// Show/hide comments based on index
comments.forEach((comment, i) => {
comment.elements.forEach(element => {
// Make sure element is still in the DOM
if (!element.parentNode) {
return;
}
if (i <= index) {
// Restore original display value
element.style.display = originalDisplayValues.get(element) || '';
// Highlight the most recent comment (current index)
if (i === index) {
// Check for dark mode
const isDarkMode = document.body.classList.contains('dark-mode') ||
document.body.classList.contains('skin-theme-clientpref-night') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
// Set appropriate background color
element.style.backgroundColor = isDarkMode ? 'rgba(255, 255, 204, 0.1)' : 'rgba(255, 255, 204, 0.5)';
element.style.transition = 'background-color 0.3s ease';
} else {
element.style.backgroundColor = '';
}
} else {
// Hide this element
element.style.display = 'none';
}
});
});
// Handle all replies in the section
const allReplies = [];
let currentElement = section.nextElementSibling;
const nextSectionLevel = section.className.match(/mw-heading(\d)/)?.[1] || '2';
while (currentElement) {
// Stop if we hit a section of the same or higher level
const headingMatch = currentElement.className?.match(/mw-heading(\d)/);
if (headingMatch) {
const currentLevel = headingMatch[1];
if (parseInt(currentLevel) <= parseInt(nextSectionLevel)) {
break;
}
}
// Find replies
const replies = currentElement.querySelectorAll('dd:not([data-mw-comment-start]), dl dl');
replies.forEach(reply => {
// Only include replies that aren't themselves comment starts
if (!reply.querySelector('[data-mw-comment-start]')) {
allReplies.push(reply);
// Store original display value if not already stored
if (!originalDisplayValues.has(reply)) {
originalDisplayValues.set(reply, reply.style.display || '');
}
}
});
currentElement = currentElement.nextElementSibling;
}
allReplies.forEach(reply => {
// Make sure reply is still in the DOM
if (!reply.parentNode) {
return;
}
// Hide all replies by default, then show only those that should be visible
reply.style.display = 'none';
const replyTimestamp = reply.querySelector('.ext-discussiontools-init-timestamplink');
if (replyTimestamp) {
// Parse reply timestamp
const replyTimeText = replyTimestamp.textContent;
const replyMatch = replyTimeText.match(/(\d{1,2}):(\d{2}), (\d{1,2}) (\w+) (\d{4})/);
if (replyMatch) {
const [, hours, minutes, day, month, year] = replyMatch;
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const monthIndex = monthNames.indexOf(month);
const replyTime = new Date(year, monthIndex, day, hours, minutes);
// Only show replies that are older than or equal to the current comment's timestamp
if (replyTime <= comments[index].timestamp) {
reply.style.display = originalDisplayValues.get(reply) || '';
}
}
}
});
// Scroll to the current comment only if not initial load and not the first comment
if (!isInitialLoad && index > 0 && comments[index].elements[0].parentNode) {
comments[index].elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Clear the initial load flag after first run
if (isInitialLoad) {
isInitialLoad = false;
}
} finally {
updateInProgress = false;
// Process any pending update
if (pendingUpdate !== null) {
const nextUpdate = pendingUpdate;
pendingUpdate = null;
updateVisibleComments(nextUpdate);
}
}
}
// Debounced update function for slider input
let sliderTimeout;
function debouncedUpdate(value) {
clearTimeout(sliderTimeout);
sliderTimeout = setTimeout(() => {
updateVisibleComments(parseInt(value));
}, 50); // Small delay to prevent rapid updates
}
// Event handlers - scoped to this section's slider
slider.addEventListener('input', (e) => {
debouncedUpdate(e.target.value);
});
prevButton.addEventListener('click', () => {
const currentValue = parseInt(slider.value);
if (currentValue > 0) {
slider.value = currentValue - 1;
updateVisibleComments(currentValue - 1);
}
});
nextButton.addEventListener('click', () => {
const currentValue = parseInt(slider.value);
if (currentValue < comments.length - 1) {
slider.value = currentValue + 1;
updateVisibleComments(currentValue + 1);
}
});
resetButton.addEventListener('click', () => {
slider.value = comments.length - 1;
updateVisibleComments(comments.length - 1);
});
// Assemble the UI
sliderContainer.appendChild(sliderLabel);
sliderContainer.appendChild(commentCounter);
sliderContainer.appendChild(slider);
buttonContainer.appendChild(prevButton);
buttonContainer.appendChild(nextButton);
buttonContainer.appendChild(resetButton);
sliderContainer.appendChild(buttonContainer);
// Add class for styling reference
sliderContainer.classList.add('timeline-slider');
// Insert after the section header
const sectionBar = section.querySelector('.ext-discussiontools-init-section-bar');
if (sectionBar) {
sectionBar.insertAdjacentElement('afterend', sliderContainer);
} else {
section.insertAdjacentElement('afterbegin', sliderContainer);
}
// Initialize with all comments visible
updateVisibleComments(comments.length - 1);
}
// Add timeline sliders to all discussion sections
function addTimelineSliders() {
const sections = document.querySelectorAll('div.mw-heading2');
sections.forEach(section => {
if (section.querySelector('h2') && !section.querySelector('.timeline-slider')) {
createTimelineSlider(section);
}
});
}
// Wait for page to load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addTimelineSliders);
} else {
addTimelineSliders();
}
})();