Files
blog/themes/Ying/assets/js/modules/paragraph-comments.js
T
2026-01-31 21:27:06 +08:00

565 lines
19 KiB
JavaScript

// Paragraph Comments Module
(function() {
var POPUP_ID = 'selection-popup';
// CSS for Paragraph Comments
var style = document.createElement('style');
style.innerHTML = `
/* Selection Popup */
#${POPUP_ID} {
position: absolute;
display: none;
background: var(--artalk-editer-color, #333);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
z-index: 2147483647;
transform: translate(-50%, -120%);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transition: opacity 0.2s, transform 0.2s;
pointer-events: auto;
align-items: center;
gap: 6px;
white-space: nowrap;
animation: popIn 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
}
#${POPUP_ID}::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: var(--artalk-editer-color, #333) transparent transparent transparent;
}
@keyframes popIn {
0% { transform: translate(-50%, -100%) scale(0.9); opacity: 0; }
100% { transform: translate(-50%, -120%) scale(1); opacity: 1; }
}
/* Paragraph Comment Marker - Pure Text, No Styles */
.paragraph-comment-marker {
text-decoration: none !important;
border-bottom: none !important;
background: transparent !important;
box-shadow: none !important;
cursor: text;
color: inherit;
}
/* Comment Count Bubble - Minimal SVG Style */
.paragraph-comment-count {
display: inline-flex;
align-items: center;
justify-content: center;
height: 16px;
padding: 0 4px;
background: transparent;
color: var(--erji-color, #999);
border-radius: 4px;
font-size: 11px;
font-weight: 500;
margin-left: 2px;
vertical-align: top;
line-height: 1;
cursor: pointer;
position: relative;
top: 2px;
user-select: none;
z-index: 10;
transition: all 0.2s ease;
gap: 2px;
}
.paragraph-comment-count svg {
width: 12px;
height: 12px;
fill: currentColor;
opacity: 0.7;
}
.paragraph-comment-count:hover {
color: var(--theme-color, #4a90e2);
background: rgba(0,0,0,0.03);
}
.paragraph-comment-count:hover svg {
opacity: 1;
transform: scale(1.1);
}
/* Comments Preview Modal */
.pc-modal {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.pc-modal.show {
opacity: 1;
pointer-events: auto;
}
.pc-modal-content {
background: var(--background-color, #fff);
width: 90%;
max-width: 500px;
max-height: 80vh;
border-radius: 12px;
display: flex;
flex-direction: column;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
transform: translateY(20px);
transition: transform 0.3s;
}
.pc-modal.show .pc-modal-content {
transform: translateY(0);
}
.pc-header {
padding: 15px;
border-bottom: 1px solid var(--frame-color, #eee);
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
}
.pc-body {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.pc-close {
cursor: pointer;
font-size: 20px;
opacity: 0.6;
}
.pc-close:hover { opacity: 1; }
.pc-quote-text {
padding: 10px;
background: var(--bankuai-color, #f6f6f6);
border-left: 3px solid var(--text-highlight-color, #666);
margin-bottom: 15px;
font-size: 0.9em;
color: var(--erji-color, #666);
}
.pc-comment-item {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid var(--frame-color, #eee);
}
.pc-comment-item:last-child { border-bottom: none; margin-bottom: 0; }
.pc-user { font-weight: bold; font-size: 0.9em; margin-bottom: 5px; }
.pc-content { font-size: 0.95em; }
[data-theme="dark"] .pc-modal-content {
background: var(--frame-dark-color, #1f2933);
color: var(--text-content-dark-color, #d1d5db);
}
[data-theme="dark"] .pc-quote-text {
background: rgba(0,0,0,0.2);
}
`;
document.head.appendChild(style);
// Create Popup Element
var popup = document.createElement('div');
popup.id = POPUP_ID;
popup.innerHTML = '<span class="icon" style="width:14px;height:14px;fill:currentColor;display:inline-block;"><svg viewBox="0 0 1024 1024" width="100%" height="100%"><path d="M576 128v298.666667h298.666667c0 164.906667-133.76 298.666667-298.666667 298.666666v170.666667c259.2 0 469.333333-210.133333 469.333333-469.333333H576zM149.333333 725.333333c0-164.906667 133.76-298.666667 298.666667-298.666666V128C188.8 128 0 338.133333 0 597.333333h469.333333v-298.666666H170.666667A298.666667 298.666667 0 0 0 149.333333 725.333333z"></path></svg></span> 评论';
document.body.appendChild(popup);
// Create Modal Element
var modal = document.createElement('div');
modal.className = 'pc-modal';
modal.innerHTML = `
<div class="pc-modal-content">
<div class="pc-header">
<span>引用评论</span>
<span class="pc-close">&times;</span>
</div>
<div class="pc-body"></div>
</div>
`;
document.body.appendChild(modal);
// State
var selectedText = '';
var range = null;
var isInitialized = false;
// Initialization
function init() {
if (!isInitialized) {
document.addEventListener('mouseup', handleSelection);
document.addEventListener('touchend', handleSelection); // Mobile support
popup.addEventListener('click', handleQuoteClick);
modal.querySelector('.pc-close').addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) closeModal();
});
isInitialized = true;
}
// Hook into Artalk
tryHookArtalk();
}
function tryHookArtalk() {
if (window.artalk) {
setupArtalkHook(window.artalk);
} else {
// Performance: Check less frequently (1s) and stop after 10s
var checkCount = 0;
var checkArtalk = setInterval(function() {
checkCount++;
if (window.artalk) {
clearInterval(checkArtalk);
setupArtalkHook(window.artalk);
}
if (checkCount > 10) clearInterval(checkArtalk); // 10s timeout
}, 1000);
}
}
function setupArtalkHook(artalk) {
// Initial load
var list = artalk.getCommentList();
if (list && list.length > 0) {
processComments();
}
// Listen for updates
artalk.on('list-loaded', function() {
setTimeout(processComments, 500);
});
}
// Performance Optimized: Build Index Once
function buildDocumentIndex(root) {
var textNodes = [];
var allText = "";
var nodeOffsets = [];
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
var node;
while (node = walker.nextNode()) {
if (node.parentNode && (
node.parentNode.tagName === 'SCRIPT' ||
node.parentNode.tagName === 'STYLE' ||
node.parentNode.classList.contains('paragraph-comment-marker') ||
node.parentNode.classList.contains('paragraph-comment-count')
)) continue;
// Skip code blocks
if (node.parentElement.closest('pre') || node.parentElement.closest('code')) continue;
nodeOffsets.push(allText.length);
textNodes.push(node);
allText += node.nodeValue;
}
return { textNodes, allText, nodeOffsets };
}
function processComments() {
if (!window.artalk) return;
// Cleanup old markers
document.querySelectorAll('.paragraph-comment-marker').forEach(el => {
var parent = el.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(el.innerText), el);
parent.normalize();
}
});
document.querySelectorAll('.paragraph-comment-count').forEach(el => el.remove());
var comments = window.artalk.getCommentList();
var quoteMap = {};
// Group comments by quote
comments.forEach(c => {
var content = c.content_marked || c.content;
// Extract text from HTML blockquotes
var tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
var blockquotes = tempDiv.querySelectorAll('blockquote');
blockquotes.forEach(bq => {
var quoteText = bq.textContent.trim();
if (quoteText) {
if (!quoteMap[quoteText]) quoteMap[quoteText] = [];
if (!quoteMap[quoteText].some(existing => existing.id === c.id)) {
quoteMap[quoteText].push(c);
}
}
});
// Regex fallback
if (!blockquotes.length && c.content && !c.content.includes('<')) {
var match = c.content.match(/^> (.*?)(?:\n|$)/gm);
if (match) {
match.forEach(m => {
var q = m.replace(/^> /, '').trim();
if (q) {
if (!quoteMap[q]) quoteMap[q] = [];
if (!quoteMap[q].some(existing => existing.id === c.id)) {
quoteMap[q].push(c);
}
}
});
}
}
});
var postContent = document.querySelector('.post-content') ||
document.querySelector('.article-content') ||
document.querySelector('article');
if (!postContent) return;
// Build Index ONCE
var docData = buildDocumentIndex(postContent);
Object.keys(quoteMap).forEach(quote => {
if (quote.length < 2) return;
highlightQuote(docData, quote, quoteMap[quote]);
});
}
// Improved Highlight Logic using Pre-built Index
function highlightQuote(docData, quote, comments) {
if (!quote || quote.length < 5) return;
var { allText, textNodes, nodeOffsets } = docData;
// Fuzzy Match
var cleanQuote = quote.trim().replace(/\s+/g, ' ');
var cleanAllText = allText.replace(/\s+/g, ' ');
var matchIndex = cleanAllText.indexOf(cleanQuote);
if (matchIndex === -1) {
matchIndex = allText.indexOf(quote);
if (matchIndex === -1) matchIndex = allText.indexOf(quote.trim());
}
if (matchIndex === -1) return;
// Map back to raw index
var currentCleanIndex = 0;
var rawStart = -1;
var rawEnd = -1;
var inWhitespace = false;
for (var i = 0; i < allText.length; i++) {
var char = allText[i];
var isSpace = /\s/.test(char);
if (isSpace) {
if (!inWhitespace) {
if (currentCleanIndex === matchIndex) rawStart = i;
currentCleanIndex++;
}
inWhitespace = true;
} else {
if (currentCleanIndex === matchIndex) rawStart = i;
currentCleanIndex++;
inWhitespace = false;
}
if (rawStart !== -1 && currentCleanIndex === matchIndex + cleanQuote.length) {
rawEnd = i + 1;
break;
}
}
if (rawStart !== -1 && rawEnd !== -1) {
rangeFromOffsets(textNodes, nodeOffsets, rawStart, rawEnd - rawStart, comments);
}
}
function rangeFromOffsets(textNodes, nodeOffsets, start, length, comments) {
var end = start + length;
var range = document.createRange();
var startNode = null, startOffset = 0;
var endNode = null, endOffset = 0;
for (var i = 0; i < textNodes.length; i++) {
var nodeStart = nodeOffsets[i];
var nodeEnd = nodeStart + textNodes[i].nodeValue.length;
if (!startNode && start >= nodeStart && start < nodeEnd) {
startNode = textNodes[i];
startOffset = start - nodeStart;
}
if (!endNode && end > nodeStart && end <= nodeEnd) {
endNode = textNodes[i];
endOffset = end - nodeStart;
}
if (startNode && endNode) break;
}
if (startNode && endNode) {
try {
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
wrapRange(range, comments);
} catch (e) {
// Ignore
}
}
}
function wrapRange(range, comments) {
try {
var contents = range.extractContents();
var span = document.createElement('span');
span.className = 'paragraph-comment-marker';
span.appendChild(contents);
range.insertNode(span);
addBubble(span, comments.length, comments);
} catch (e) {
// Ignore
}
}
function addBubble(marker, count, comments) {
var bubble = document.createElement('sup');
bubble.className = 'paragraph-comment-count';
// Simple Chat Icon SVG
bubble.innerHTML = `<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"></path></svg> <span>${count}</span>`;
marker.appendChild(bubble);
bubble.addEventListener('click', function(e) {
e.stopPropagation();
showCommentsModal(null, comments);
});
}
function handleSelection(e) {
setTimeout(function() {
var sel = window.getSelection();
if (sel.rangeCount > 0) {
var r = sel.getRangeAt(0);
var text = sel.toString().trim();
var postContent = document.querySelector('.post-content') ||
document.querySelector('.article-content') ||
document.querySelector('article');
if (!postContent) return;
var isInside = postContent.contains(r.commonAncestorContainer);
if (text.length > 0 && isInside) {
selectedText = text;
range = r;
showPopup(r);
} else {
hidePopup();
}
} else {
hidePopup();
}
}, 10);
}
function showPopup(r) {
var rect = r.getBoundingClientRect();
popup.style.display = 'flex';
var top = rect.top + window.scrollY - 40;
var left = rect.left + window.scrollX + (rect.width / 2);
popup.style.top = top + 'px';
popup.style.left = left + 'px';
popup.style.opacity = 1;
}
function hidePopup() {
popup.style.display = 'none';
popup.style.opacity = 0;
}
function handleQuoteClick() {
if (!window.artalk) return;
var commentsEl = document.getElementById('Comments');
if (commentsEl) commentsEl.scrollIntoView({ behavior: 'smooth' });
var quoteContent = `> ${selectedText}\n\n`;
var editor = window.artalk.editor;
if (editor && typeof editor.setContent === 'function' && typeof editor.getContent === 'function') {
editor.setContent(quoteContent + editor.getContent());
editor.focus();
} else {
var textarea = document.querySelector('.atk-textarea');
if (textarea) {
textarea.value = quoteContent + textarea.value;
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
}
}
hidePopup();
window.getSelection().removeAllRanges();
}
function showCommentsModal(quote, comments) {
var body = modal.querySelector('.pc-body');
body.innerHTML = '';
if (!quote && comments.length > 0) {
var firstContent = comments[0].content_marked || comments[0].content;
var match = firstContent.match(/<blockquote>(.*?)<\/blockquote>/) || firstContent.match(/^> (.*?)(?:\n|$)/);
if (match) quote = match[1];
}
if (quote) {
body.innerHTML += `<div class="pc-quote-text">${quote}</div>`;
}
comments.forEach(c => {
var item = document.createElement('div');
item.className = 'pc-comment-item';
var cleanContent = c.content.replace(/^> .*?\n+/g, '').replace(/<blockquote>.*?<\/blockquote>/g, '');
var badgeHtml = '';
if (c.badge) {
badgeHtml = `<span style="background:${c.badge_color || '#000'};color:#fff;padding:1px 4px;border-radius:4px;font-size:0.8em;margin-left:5px;">${c.badge}</span>`;
}
item.innerHTML = `
<div class="pc-user">${c.nick}${badgeHtml} <span style="font-weight:normal;font-size:0.8em;color:#999;float:right">${c.date}</span></div>
<div class="pc-content">${cleanContent}</div>
`;
body.appendChild(item);
});
modal.classList.add('show');
}
function closeModal() {
modal.classList.remove('show');
}
window.initParagraphComments = init;
init();
})();