638 lines
21 KiB
JavaScript
638 lines
21 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: 8px 16px;
|
|
border-radius: 50px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
z-index: 2147483647;
|
|
transform: translate(-50%, -120%);
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
|
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);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
font-weight: 500;
|
|
}
|
|
#${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;
|
|
// 使用 Remix Icon 的引用图标
|
|
popup.innerHTML = '<i class="ri-double-quotes-l" style="font-size:14px;margin-right:4px;"></i> <span>引用评论</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">×</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);
|
|
}
|
|
}
|
|
|
|
// Safe Get Comment List Helper
|
|
function getCommentListSafe(artalkInstance) {
|
|
var inst = artalkInstance || window.artalk;
|
|
if (!inst) return [];
|
|
|
|
if (typeof inst.getCommentList === 'function') return inst.getCommentList();
|
|
if (typeof inst.getComments === 'function') return inst.getComments();
|
|
|
|
// Try context access (Artalk v2)
|
|
if (inst.ctx && typeof inst.ctx.get === 'function') {
|
|
var list = inst.ctx.get('list');
|
|
if (list && typeof list.getComments === 'function') {
|
|
return list.getComments();
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function setupArtalkHook(artalk) {
|
|
// Initial load
|
|
var list = getCommentListSafe(artalk);
|
|
if (list && list.length > 0) {
|
|
processComments();
|
|
}
|
|
|
|
// Listen for updates
|
|
if (typeof artalk.on === 'function') {
|
|
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 = getCommentListSafe(window.artalk);
|
|
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;
|
|
}
|
|
|
|
// Custom Smooth Scroll with Easing
|
|
function smoothScrollTo(targetPosition, duration) {
|
|
var startPosition = window.scrollY;
|
|
var distance = targetPosition - startPosition;
|
|
var startTime = null;
|
|
|
|
function animation(currentTime) {
|
|
if (startTime === null) startTime = currentTime;
|
|
var timeElapsed = currentTime - startTime;
|
|
var run = ease(timeElapsed, startPosition, distance, duration);
|
|
window.scrollTo(0, run);
|
|
if (timeElapsed < duration) requestAnimationFrame(animation);
|
|
}
|
|
|
|
// Ease-in-out Cubic
|
|
function ease(t, b, c, d) {
|
|
t /= d / 2;
|
|
if (t < 1) return c / 2 * t * t * t + b;
|
|
t -= 2;
|
|
return c / 2 * (t * t * t + 2) + b;
|
|
}
|
|
|
|
requestAnimationFrame(animation);
|
|
}
|
|
|
|
function handleQuoteClick() {
|
|
if (!window.artalk) return;
|
|
|
|
var commentsEl = document.getElementById('Comments');
|
|
if (commentsEl) {
|
|
// Target the editor area directly if possible, or the top of comments
|
|
// Adjust offset to show the editor more clearly (e.g. 100px from top)
|
|
var targetEl = document.querySelector('.atk-main-editor') || commentsEl;
|
|
var elementPosition = targetEl.getBoundingClientRect().top + window.scrollY;
|
|
|
|
// Align to 1/3 of the screen height (more comfortable for reading/typing)
|
|
var offsetPosition = elementPosition - (window.innerHeight / 3);
|
|
|
|
// Use custom smooth scroll (800ms)
|
|
smoothScrollTo(offsetPosition, 800);
|
|
|
|
// Highlight effect after scroll (Subtle Scale only, no blue border)
|
|
setTimeout(function() {
|
|
var editorEl = document.querySelector('.atk-main-editor');
|
|
if (editorEl) {
|
|
editorEl.style.transition = 'transform 0.3s ease';
|
|
editorEl.style.transform = 'scale(1.02)';
|
|
|
|
setTimeout(function() {
|
|
editorEl.style.transform = '';
|
|
}, 600);
|
|
}
|
|
}, 800);
|
|
}
|
|
|
|
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();
|
|
|
|
})();
|