(function () { 'use strict'; const article = document.querySelector('[data-document-id]'); if (!article) return; const documentId = article.dataset.documentId; const documentHash = article.dataset.documentHash; const canComment = article.dataset.canComment === 'true'; 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 commentToggle = document.querySelector('[data-comment-toggle]'); const watchButton = document.querySelector('[data-document-watch]'); const historyButtons = document.querySelectorAll('[data-comments-history]'); const commentVisibilityKey = `cairnquire:document-comments:${documentId}`; const commentsByAnchor = new Map(); const elementsByAnchor = new Map(); let includeResolved = false; let watching = false; function readCommentVisibility() { try { return window.localStorage.getItem(commentVisibilityKey) === 'hidden'; } catch (err) { return false; } } function commentsHidden() { return !workspaceShell?.classList.contains('comments-visible'); } function setCommentsHidden(hidden) { article.classList.toggle('comments-hidden', hidden); workspaceShell?.classList.toggle('comments-visible', !hidden); try { window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible'); } catch (err) { // Storage can be unavailable in private browsing contexts. } 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(); } } setCommentsHidden(readCommentVisibility()); 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++) { hash = ((hash << 5) - hash) + text.charCodeAt(i); hash &= hash; } return 'h' + Math.abs(hash).toString(36); } function addCommentButton(el, anchorHash) { const button = document.createElement('button'); button.className = 'comment-anchor-btn'; button.type = 'button'; button.title = 'Add comment'; button.setAttribute('aria-label', 'Add comment'); button.innerHTML = ` Add comment`; button.addEventListener('click', () => createComment(anchorHash)); el.appendChild(button); } function updateCommentButtonState(el, comments) { const button = el.querySelector('.comment-anchor-btn'); const count = comments.filter(comment => !comment.resolvedAt).length; if (button) { const hasComments = count > 0; button.classList.toggle('comment-anchor-btn--has-comments', hasComments); const label = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment'; button.setAttribute('aria-label', label); button.title = label; const countNode = button.querySelector('.comment-anchor-count'); if (countNode) { countNode.hidden = !hasComments; countNode.textContent = count > 99 ? '99+' : String(count); } } el.dataset.hasComments = comments.length > 0 ? 'true' : 'false'; } async function createComment(anchorHash, parentId) { const promptText = parentId ? 'Reply to this comment:' : 'Comment on this section:'; const content = window.prompt(promptText); if (!content || !content.trim()) return; try { const response = await fetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ documentId, versionHash: documentHash, content: content.trim(), anchorHash, parentId: parentId || undefined, }), }); if (!response.ok) throw new Error(await response.text()); await loadCommentsForAnchor(anchorHash); } catch (err) { window.alert('Failed to post comment: ' + err.message); } } async function resolveThread(comment, anchorHash) { if (!window.confirm('Resolve this comment thread?')) return; try { const response = await fetch(`/api/comments/${encodeURIComponent(comment.id)}/resolve`, { method: 'POST' }); if (!response.ok) throw new Error(await response.text()); await loadCommentsForAnchor(anchorHash); } catch (err) { window.alert('Failed to resolve comment: ' + err.message); } } async function loadCommentsForAnchor(anchorHash) { const element = elementsByAnchor.get(anchorHash); if (!element) return; const params = new URLSearchParams({ document_id: documentId, anchor_hash: anchorHash }); if (includeResolved) params.set('include_resolved', 'true'); try { const response = await fetch(`/api/comments?${params}`); if (!response.ok) return; const data = await response.json(); const comments = data.comments || []; commentsByAnchor.set(anchorHash, comments); renderInlineComments(element, anchorHash, comments); renderCommentsAside(); updateStripMarkers(); } catch (err) { console.error('load comments', err); } } function buildCommentForest(comments) { const nodes = new Map(comments.map(comment => [comment.id, { comment, children: [] }])); const roots = []; comments.forEach(comment => { const node = nodes.get(comment.id); const parent = comment.parentId ? nodes.get(comment.parentId) : null; if (parent) parent.children.push(node); else roots.push(node); }); return roots; } function renderThreadNode(node, anchorHash, variant) { const comment = node.comment; const wrapper = document.createElement('div'); wrapper.className = `comment-thread${comment.resolvedAt ? ' is-resolved' : ''}`; wrapper.dataset.commentId = comment.id; const item = document.createElement('div'); item.className = variant === 'aside' ? 'comments-aside-item__comment' : 'comment-item'; const meta = document.createElement('div'); meta.className = variant === 'aside' ? 'comments-aside-item__meta' : 'comment-meta'; const author = document.createElement('strong'); author.textContent = comment.authorName || 'Anonymous'; const time = document.createElement('time'); time.textContent = formatDate(comment.createdAt); meta.append(author, time); if (comment.resolvedAt) { const status = document.createElement('span'); status.className = 'comment-status'; status.textContent = 'Resolved'; meta.appendChild(status); } const body = document.createElement('div'); body.className = variant === 'aside' ? 'comments-aside-item__body' : 'comment-body'; body.textContent = comment.content; item.append(meta, body); if (canComment && !comment.resolvedAt) { const actions = document.createElement('div'); actions.className = 'comment-actions'; const reply = document.createElement('button'); reply.type = 'button'; reply.className = 'comment-action'; reply.textContent = 'Reply'; reply.addEventListener('click', () => createComment(anchorHash, comment.id)); actions.appendChild(reply); if (!comment.parentId) { const resolve = document.createElement('button'); resolve.type = 'button'; resolve.className = 'comment-action'; resolve.textContent = 'Resolve thread'; resolve.addEventListener('click', () => resolveThread(comment, anchorHash)); actions.appendChild(resolve); } item.appendChild(actions); } wrapper.appendChild(item); if (node.children.length) { const replies = document.createElement('div'); replies.className = 'comment-thread__replies'; node.children.forEach(child => replies.appendChild(renderThreadNode(child, anchorHash, variant))); wrapper.appendChild(replies); } return wrapper; } function renderInlineComments(el, anchorHash, comments) { el.querySelector('.comment-list')?.remove(); updateCommentButtonState(el, comments); if (!comments.length) return; const list = document.createElement('div'); list.className = 'comment-list'; buildCommentForest(comments).forEach(root => list.appendChild(renderThreadNode(root, anchorHash, 'inline'))); el.appendChild(list); } function buildAsideItem(anchorHash, el, comments) { if (!comments.length) return null; const asideItem = document.createElement('div'); asideItem.className = 'comments-aside-item'; asideItem.dataset.anchorHash = anchorHash; const target = document.createElement('button'); target.className = 'comments-aside-item__target'; target.type = 'button'; target.textContent = el.dataset.commentTarget || 'Commented section'; target.addEventListener('click', () => el.scrollIntoView({ behavior: 'smooth', block: 'center' })); asideItem.appendChild(target); buildCommentForest(comments).forEach(root => asideItem.appendChild(renderThreadNode(root, anchorHash, 'aside'))); return asideItem; } function renderCommentsInto(container) { if (!container) return; container.innerHTML = ''; let rendered = 0; elementsByAnchor.forEach((el, anchorHash) => { const item = buildAsideItem(anchorHash, el, commentsByAnchor.get(anchorHash) || []); if (item) { container.appendChild(item); rendered++; } }); if (!rendered) { container.innerHTML = `

${includeResolved ? 'No comments found.' : 'No active comments yet.'}

`; } } function renderCommentsAside() { renderCommentsInto(commentsAsideList); renderCommentsInto(commentsAsideListMobile); } function updateStripMarkers() { if (!stripTrack) return; stripTrack.innerHTML = ''; const shell = document.querySelector('.document-shell'); if (!shell) return; elementsByAnchor.forEach((el, anchorHash) => { if (!(commentsByAnchor.get(anchorHash) || []).length) return; const rect = el.getBoundingClientRect(); const shellRect = shell.getBoundingClientRect(); const relativeTop = rect.top - shellRect.top + shell.scrollTop; const percent = (relativeTop / shell.scrollHeight) * 100; const marker = document.createElement('button'); marker.className = 'document-comments-strip__marker'; marker.type = 'button'; marker.style.top = Math.max(0, Math.min(95, percent)) + '%'; marker.dataset.anchorHash = anchorHash; marker.title = 'Jump to comments'; marker.addEventListener('click', () => { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (commentsHidden()) setCommentsHidden(false); commentsAsideList?.querySelector(`[data-anchor-hash="${anchorHash}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); stripTrack.appendChild(marker); }); } function initBidirectionalHover() { const body = document.querySelector('.markdown-body'); if (!body || !commentsAsideList) return; body.addEventListener('mouseover', event => toggleHighlight(event.target.closest('.commentable'), true)); body.addEventListener('mouseout', event => toggleHighlight(event.target.closest('.commentable'), false)); commentsAsideList.addEventListener('mouseover', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), true)); commentsAsideList.addEventListener('mouseout', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), false)); } function toggleHighlight(el, active) { if (!el) return; const hash = el.dataset.anchorHash; el.classList.toggle('is-highlighted', active); commentsAsideList?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-highlighted', active); stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-active', active); } function toggleAsideHighlight(item, active) { if (!item) return; toggleHighlight(elementsByAnchor.get(item.dataset.anchorHash), active); } function setCommentsSheetOpen(open) { if (!commentsAside) return; commentsAside.classList.toggle('is-open', open); commentsSheetToggle?.setAttribute('aria-expanded', String(open)); if (open) { document.body.classList.add('drawer-open'); renderCommentsAside(); } else if (!document.querySelector('[data-picker-drawer].is-open, [data-meta-drawer].is-open')) { document.body.classList.remove('drawer-open'); } } commentsSheetToggle?.addEventListener('click', () => setCommentsSheetOpen(!commentsAside?.classList.contains('is-open'))); commentsSheetClose?.addEventListener('click', () => setCommentsSheetOpen(false)); function setWatchState(nextWatching) { watching = nextWatching; if (!watchButton) return; watchButton.setAttribute('aria-pressed', String(watching)); const label = watchButton.querySelector('[data-document-watch-label]'); if (label) label.textContent = watching ? 'Unwatch document' : 'Watch document'; } async function loadWatchStatus() { if (!watchButton) return; try { const response = await fetch(`/api/watch/status?document_id=${encodeURIComponent(documentId)}`); if (!response.ok) return; const data = await response.json(); setWatchState(Boolean(data.watching)); } catch (err) { console.error('load watch status', err); } } async function toggleWatch() { if (!watchButton) return; watchButton.disabled = true; try { const response = await fetch('/api/watch/document', { method: watching ? 'DELETE' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ documentId }), }); if (!response.ok) throw new Error(await response.text()); setWatchState(!watching); } catch (err) { window.alert('Failed to update watch: ' + err.message); } finally { watchButton.disabled = false; } } watchButton?.addEventListener('click', toggleWatch); function updateHistoryButtons() { historyButtons.forEach(button => { button.setAttribute('aria-pressed', String(includeResolved)); button.textContent = includeResolved ? 'Hide resolved' : 'Show resolved'; }); } historyButtons.forEach(button => button.addEventListener('click', async () => { includeResolved = !includeResolved; updateHistoryButtons(); await Promise.all(Array.from(elementsByAnchor.keys(), loadCommentsForAnchor)); })); function formatDate(iso) { return new Date(iso).toLocaleString(); } function init() { const body = document.querySelector('.markdown-body'); if (!body) return; const commentables = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre'); commentables.forEach(el => { const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim(); const anchorHash = hashParagraph(el); if (!anchorHash) return; el.dataset.anchorHash = anchorHash; el.dataset.commentTarget = targetText.length > 120 ? targetText.slice(0, 120) + '…' : targetText; el.classList.add('commentable'); elementsByAnchor.set(anchorHash, el); if (canComment) addCommentButton(el, anchorHash); loadCommentsForAnchor(anchorHash); }); renderCommentsAside(); initBidirectionalHover(); loadWatchStatus(); updateHistoryButtons(); const shell = document.querySelector('.document-shell'); shell?.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers)); window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers)); setTimeout(updateStripMarkers, 100); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();