(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 commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]'); const stripTrack = document.querySelector('[data-comments-strip-track]'); const commentsAside = document.querySelector('[data-comments-aside]'); const commentsSheetToggle = document.querySelector('[data-toggle-comments]'); const commentsSheetClose = document.querySelector('[data-close-comments]'); 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 = ` Add comment `; 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 = `
${escapeHtml(c.authorName || 'Anonymous')}
${escapeHtml(c.content)}
`; list.appendChild(item); }); el.appendChild(list); renderCommentsAside(); updateStripMarkers(); } function buildAsideItemFor(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 null; 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 = `
${escapeHtml(c.authorName)}
${escapeHtml(c.content)}
`; asideItem.appendChild(commentEl); }); return asideItem; } function renderCommentsInto(container) { if (!container) return; container.innerHTML = ''; const body = document.querySelector('.markdown-body'); if (!body) return; const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]'); if (!commentables.length) { container.innerHTML = '

No comments yet.

'; return; } commentables.forEach(el => { const item = buildAsideItemFor(el); if (item) container.appendChild(item); }); } function renderCommentsAside() { renderCommentsInto(commentsAsideList); renderCommentsInto(commentsAsideListMobile); } 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 commentsSheetOpen() { return commentsAside?.classList.contains('is-open'); } function setCommentsSheetOpen(open) { if (!commentsAside) return; commentsAside.classList.toggle('is-open', open); if (commentsSheetToggle) { commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments'); } if (open) { document.body.classList.add('drawer-open'); renderCommentsAside(); } else { if (!pickerOpen() && !metaOpen()) { document.body.classList.remove('drawer-open'); } } } function pickerOpen() { return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open'); } function metaOpen() { return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open'); } if (commentsSheetToggle) { commentsSheetToggle.addEventListener('click', () => { setCommentsSheetOpen(!commentsSheetOpen()); }); } if (commentsSheetClose) { commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false)); } 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(); } })();