Add Cloudflare email and collaboration workflows

This commit is contained in:
2026-07-22 12:59:16 -04:00
parent 09f51d1ef4
commit 4454e918c2
35 changed files with 1596 additions and 487 deletions

View File

@@ -1,23 +1,29 @@
(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 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 documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
const commentToggle = document.querySelector('[data-comment-toggle]');
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
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() {
if (!commentVisibilityKey) return false;
try {
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
} catch (err) {
@@ -25,29 +31,17 @@
}
}
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;
return !workspaceShell?.classList.contains('comments-visible');
}
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
}
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');
@@ -60,173 +54,218 @@
}
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
commentToggle.addEventListener('click', () => {
setCommentsHidden(!commentsHidden());
});
}
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++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
hash = ((hash << 5) - hash) + text.charCodeAt(i);
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 = `
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 = `
<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>
<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);
<span class="sr-only">Add comment</span>`;
button.addEventListener('click', () => createComment(anchorHash));
el.appendChild(button);
}
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);
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 promptComment(el, anchorHash) {
const text = window.prompt('Comment on this paragraph:');
if (!text || !text.trim()) return;
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 res = await fetch('/api/comments', {
const response = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
body: JSON.stringify({
documentId,
versionHash: documentHash,
content: content.trim(),
anchorHash,
parentId: parentId || undefined,
}),
});
if (!res.ok) throw new Error(await res.text());
await loadCommentsForAnchor(el, anchorHash);
if (!response.ok) throw new Error(await response.text());
await loadCommentsForAnchor(anchorHash);
} catch (err) {
window.alert('Failed to post comment: ' + err.message);
}
}
async function loadCommentsForAnchor(el, anchorHash) {
async function resolveThread(comment, anchorHash) {
if (!window.confirm('Resolve this comment thread?')) return;
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 || []);
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 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);
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);
});
el.appendChild(list);
renderCommentsAside();
updateStripMarkers();
return roots;
}
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 || '',
});
});
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 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);
});
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 = '';
const body = document.querySelector('.markdown-body');
if (!body) return;
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
if (!commentables.length) {
container.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
return;
}
commentables.forEach(el => {
const item = buildAsideItemFor(el);
if (item) container.appendChild(item);
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 = `<p class="document-comments-aside__empty">${includeResolved ? 'No comments found.' : 'No active comments yet.'}</p>`;
}
}
function renderCommentsAside() {
@@ -237,177 +276,148 @@
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 => {
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 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.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);
// 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);
}
commentsAsideList?.querySelector(`[data-anchor-hash="${anchorHash}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
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);
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 escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
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 formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString();
}
function commentsSheetOpen() {
return commentsAside?.classList.contains('is-open');
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);
if (commentsSheetToggle) {
commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments');
}
commentsSheetToggle?.setAttribute('aria-expanded', String(open));
if (open) {
document.body.classList.add('drawer-open');
renderCommentsAside();
} else {
if (!pickerOpen() && !metaOpen()) {
document.body.classList.remove('drawer-open');
}
} else if (!document.querySelector('[data-picker-drawer].is-open, [data-meta-drawer].is-open')) {
document.body.classList.remove('drawer-open');
}
}
function pickerOpen() {
return document.querySelector('[data-picker-drawer]')?.classList.contains('is-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';
}
function metaOpen() {
return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open');
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);
}
}
if (commentsSheetToggle) {
commentsSheetToggle.addEventListener('click', () => {
setCommentsSheetOpen(!commentsSheetOpen());
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';
});
}
if (commentsSheetClose) {
commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false));
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 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;
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');
if (canComment) addCommentButton(el, hash);
loadCommentsForAnchor(el, hash);
elementsByAnchor.set(anchorHash, el);
if (canComment) addCommentButton(el, anchorHash);
loadCommentsForAnchor(anchorHash);
});
renderCommentsAside();
initBidirectionalHover();
// Update markers on scroll/resize
loadWatchStatus();
updateHistoryButtons();
const shell = document.querySelector('.document-shell');
if (shell) {
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
}
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();
}
})();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();