361 lines
13 KiB
JavaScript
361 lines
13 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
|
|
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
|
|
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
|
|
const article = document.querySelector('[data-document-path]');
|
|
const workspaceShell = article?.closest('.workspace-shell--document');
|
|
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
|
|
const stripTrack = document.querySelector('[data-comments-strip-track]');
|
|
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
|
|
const commentToggle = document.querySelector('[data-comment-toggle]');
|
|
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
|
|
|
|
function readCommentVisibility() {
|
|
if (!commentVisibilityKey) return false;
|
|
try {
|
|
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!documentId) return;
|
|
|
|
function syncVisibility() {
|
|
const hidden = readCommentVisibility();
|
|
if (article) article.classList.toggle('comments-hidden', hidden);
|
|
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
|
}
|
|
|
|
syncVisibility();
|
|
|
|
function commentsHidden() {
|
|
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false;
|
|
}
|
|
|
|
function setCommentsHidden(hidden) {
|
|
if (article) article.classList.toggle('comments-hidden', hidden);
|
|
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
|
if (commentVisibilityKey) {
|
|
try {
|
|
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
|
|
} catch (err) {
|
|
// Ignore persistence failures
|
|
}
|
|
}
|
|
if (commentToggle) {
|
|
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
|
|
commentToggle.setAttribute('aria-label', hidden ? 'Show comments' : 'Hide comments');
|
|
commentToggle.title = hidden ? 'Show comments' : 'Hide comments';
|
|
}
|
|
if (!hidden) {
|
|
renderCommentsAside();
|
|
updateStripMarkers();
|
|
}
|
|
}
|
|
|
|
if (commentToggle) {
|
|
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
|
|
commentToggle.addEventListener('click', () => {
|
|
setCommentsHidden(!commentsHidden());
|
|
});
|
|
}
|
|
|
|
function hashParagraph(el) {
|
|
const text = (el.textContent || '').trim();
|
|
if (!text) return null;
|
|
let hash = 0;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const char = text.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash;
|
|
}
|
|
return 'h' + Math.abs(hash).toString(36);
|
|
}
|
|
|
|
function addCommentButton(el, hash) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'comment-anchor-btn';
|
|
btn.type = 'button';
|
|
btn.title = 'Add comment';
|
|
btn.setAttribute('aria-label', 'Add comment');
|
|
btn.innerHTML = `
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
|
<path d="M12 8v4"></path>
|
|
<path d="M10 10h4"></path>
|
|
</svg>
|
|
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
|
|
<span class="sr-only">Add comment</span>
|
|
`;
|
|
btn.addEventListener('click', () => promptComment(el, hash));
|
|
el.appendChild(btn);
|
|
}
|
|
|
|
function updateCommentButtonState(el, count) {
|
|
const btn = el.querySelector('.comment-anchor-btn');
|
|
if (!btn) return;
|
|
const hasComments = count > 0;
|
|
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments);
|
|
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment');
|
|
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
|
|
const countNode = btn.querySelector('.comment-anchor-count');
|
|
if (countNode) {
|
|
countNode.hidden = !hasComments;
|
|
countNode.textContent = count > 99 ? '99+' : String(count);
|
|
}
|
|
}
|
|
|
|
async function promptComment(el, anchorHash) {
|
|
const text = window.prompt('Comment on this paragraph:');
|
|
if (!text || !text.trim()) return;
|
|
try {
|
|
const res = await fetch('/api/comments', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
|
|
});
|
|
if (!res.ok) throw new Error(await res.text());
|
|
await loadCommentsForAnchor(el, anchorHash);
|
|
} catch (err) {
|
|
window.alert('Failed to post comment: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function loadCommentsForAnchor(el, anchorHash) {
|
|
try {
|
|
const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`);
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
renderComments(el, data.comments || []);
|
|
} catch (err) {
|
|
console.error('load comments', err);
|
|
}
|
|
}
|
|
|
|
function renderComments(el, comments) {
|
|
const existing = el.querySelector('.comment-list');
|
|
if (existing) existing.remove();
|
|
|
|
updateCommentButtonState(el, comments.length);
|
|
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
|
|
|
|
if (!comments.length) {
|
|
renderCommentsAside();
|
|
updateStripMarkers();
|
|
return;
|
|
}
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'comment-list';
|
|
comments.forEach(c => {
|
|
const item = document.createElement('div');
|
|
item.className = 'comment-item';
|
|
item.innerHTML = `
|
|
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
|
|
<div class="comment-body">${escapeHtml(c.content)}</div>
|
|
`;
|
|
list.appendChild(item);
|
|
});
|
|
el.appendChild(list);
|
|
|
|
renderCommentsAside();
|
|
updateStripMarkers();
|
|
}
|
|
|
|
function renderCommentsAside() {
|
|
if (!commentsAsideList) return;
|
|
commentsAsideList.innerHTML = '';
|
|
|
|
const body = document.querySelector('.markdown-body');
|
|
if (!body) return;
|
|
|
|
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
|
if (!commentables.length) {
|
|
commentsAsideList.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
|
|
return;
|
|
}
|
|
|
|
commentables.forEach(el => {
|
|
const comments = [];
|
|
el.querySelectorAll('.comment-item').forEach(item => {
|
|
const meta = item.querySelector('.comment-meta');
|
|
const bodyEl = item.querySelector('.comment-body');
|
|
comments.push({
|
|
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
|
|
createdAt: meta?.querySelector('time')?.textContent || '',
|
|
content: bodyEl?.textContent || '',
|
|
});
|
|
});
|
|
if (!comments.length) return;
|
|
|
|
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
|
|
|
|
const asideItem = document.createElement('div');
|
|
asideItem.className = 'comments-aside-item';
|
|
asideItem.dataset.anchorHash = el.dataset.anchorHash;
|
|
|
|
const targetEl = document.createElement('div');
|
|
targetEl.className = 'comments-aside-item__target';
|
|
targetEl.textContent = truncated || 'Commented section';
|
|
targetEl.addEventListener('click', () => {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
});
|
|
asideItem.appendChild(targetEl);
|
|
|
|
comments.forEach(c => {
|
|
const commentEl = document.createElement('div');
|
|
commentEl.className = 'comments-aside-item__comment';
|
|
commentEl.innerHTML = `
|
|
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
|
|
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
|
|
`;
|
|
asideItem.appendChild(commentEl);
|
|
});
|
|
|
|
commentsAsideList.appendChild(asideItem);
|
|
});
|
|
|
|
}
|
|
|
|
function updateStripMarkers() {
|
|
if (!stripTrack) return;
|
|
stripTrack.innerHTML = '';
|
|
|
|
const body = document.querySelector('.markdown-body');
|
|
const shell = document.querySelector('.document-shell');
|
|
if (!body || !shell) return;
|
|
|
|
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
|
const shellRect = shell.getBoundingClientRect();
|
|
const trackHeight = stripTrack.clientHeight;
|
|
|
|
commentables.forEach(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
|
|
const percent = (relativeTop / shell.scrollHeight) * 100;
|
|
const clamped = Math.max(0, Math.min(95, percent));
|
|
|
|
const marker = document.createElement('button');
|
|
marker.className = 'document-comments-strip__marker';
|
|
marker.type = 'button';
|
|
marker.style.top = clamped + '%';
|
|
marker.dataset.anchorHash = el.dataset.anchorHash;
|
|
marker.title = 'Jump to comments';
|
|
marker.addEventListener('click', () => {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
if (commentsHidden()) setCommentsHidden(false);
|
|
// Highlight the corresponding sidebar item
|
|
const asideItem = commentsAsideList?.querySelector(`[data-anchor-hash="${el.dataset.anchorHash}"]`);
|
|
if (asideItem) {
|
|
asideItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
asideItem.classList.add('is-highlighted');
|
|
setTimeout(() => asideItem.classList.remove('is-highlighted'), 1500);
|
|
}
|
|
});
|
|
stripTrack.appendChild(marker);
|
|
});
|
|
}
|
|
|
|
let bidirectionalHoverInitialized = false;
|
|
function initBidirectionalHover() {
|
|
if (bidirectionalHoverInitialized) return;
|
|
bidirectionalHoverInitialized = true;
|
|
|
|
const body = document.querySelector('.markdown-body');
|
|
if (!body || !commentsAsideList) return;
|
|
|
|
// Body → sidebar + marker
|
|
body.addEventListener('mouseenter', (e) => {
|
|
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
|
if (!el) return;
|
|
const hash = el.dataset.anchorHash;
|
|
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
if (asideItem) asideItem.classList.add('is-highlighted');
|
|
if (marker) marker.classList.add('is-active');
|
|
el.classList.add('is-highlighted');
|
|
}, true);
|
|
|
|
body.addEventListener('mouseleave', (e) => {
|
|
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
|
if (!el) return;
|
|
const hash = el.dataset.anchorHash;
|
|
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
if (asideItem) asideItem.classList.remove('is-highlighted');
|
|
if (marker) marker.classList.remove('is-active');
|
|
el.classList.remove('is-highlighted');
|
|
}, true);
|
|
|
|
// Sidebar → paragraph + marker (event delegation so it survives re-renders)
|
|
commentsAsideList.addEventListener('mouseenter', (e) => {
|
|
const item = e.target.closest('.comments-aside-item');
|
|
if (!item) return;
|
|
const hash = item.dataset.anchorHash;
|
|
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
if (para) para.classList.add('is-highlighted');
|
|
if (marker) marker.classList.add('is-active');
|
|
item.classList.add('is-highlighted');
|
|
}, true);
|
|
|
|
commentsAsideList.addEventListener('mouseleave', (e) => {
|
|
const item = e.target.closest('.comments-aside-item');
|
|
if (!item) return;
|
|
const hash = item.dataset.anchorHash;
|
|
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
|
if (para) para.classList.remove('is-highlighted');
|
|
if (marker) marker.classList.remove('is-active');
|
|
item.classList.remove('is-highlighted');
|
|
}, true);
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatDate(iso) {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
function init() {
|
|
const body = document.querySelector('.markdown-body');
|
|
if (!body) return;
|
|
|
|
const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
|
|
paragraphs.forEach(el => {
|
|
const hash = hashParagraph(el);
|
|
if (!hash) return;
|
|
el.dataset.anchorHash = hash;
|
|
el.classList.add('commentable');
|
|
if (canComment) addCommentButton(el, hash);
|
|
loadCommentsForAnchor(el, hash);
|
|
});
|
|
|
|
renderCommentsAside();
|
|
initBidirectionalHover();
|
|
|
|
// Update markers on scroll/resize
|
|
const shell = document.querySelector('.document-shell');
|
|
if (shell) {
|
|
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
|
|
}
|
|
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
|
|
// Initial marker update after layout settles
|
|
setTimeout(updateStripMarkers, 100);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})(); |