Files
assembly/quartz/components/scripts/inlineComments.inline.ts
T
Corey PettyandClaude Opus 4.8 9bd5ceba1b Use a GitHub App so commenters aren't asked for public_repo
An OAuth App can only request coarse scopes; the narrowest one permitting
comments on a public repo's Discussions is `public_repo`, which grants
write access to every public repository the commenter owns. That is an
unreasonable ask for a reader who wants to leave one inline note.

A GitHub App's permissions are fixed by the App definition, so a commenter
grants exactly `Discussions: write` on logos-co/assembly and nothing else.

Worker:
- drop `scope` from the authorize URL; App permissions replace it
- pass expires_in / refresh_token through the callback postMessage
- add POST /api/auth/refresh to trade a refresh token for a fresh user
  token, and allow POST in the CORS methods

Client:
- store a session object (token, expiresAt, refreshToken, refreshExpiresAt)
  rather than a bare string, parsing a legacy bare token defensively so
  existing sign-ins are not broken
- refresh silently in ensureToken() with a 60s skew before expiry, falling
  back to the sign-in popup; keep login() free of any preceding await so
  the popup is not blocked
- treat an expired-but-refreshable session as signed in, so the composer
  does not flash signed-out every 8 hours

GitHub Apps are governed by installation rather than the org's OAuth App
access restrictions, so the previous org-approval step is replaced by an
owner installing the App on the repo.

Both configurations work: if the App has token expiration disabled, GitHub
omits the expiry fields and the client treats the token as non-expiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:49:39 -04:00

1015 lines
34 KiB
TypeScript

import { computePosition, offset, flip, shift, autoUpdate } from "@floating-ui/dom"
// ────────────────────────────────────────────────────────────────────────
// Inline, anchored comments for Quartz, backed by GitHub Discussions.
//
// • Anchoring uses a W3C-style text-quote + text-position selector so a
// comment survives reflow and minor content edits.
// • Reads go through the serverless worker (`apiBase`) so anonymous visitors
// can see highlights without logging in.
// • Writes go straight to GitHub's GraphQL API with the user's own token.
//
// See docs-internal/inline-comments-design.md for the full design.
// ────────────────────────────────────────────────────────────────────────
type TextQuoteAnchor = {
v: number
exact: string
prefix: string
suffix: string
start: number
end: number
slug: string
}
type CommentAuthor = { login: string; avatarUrl: string }
type RawComment = {
id: string
author: CommentAuthor
body: string
bodyHTML: string
createdAt: string
url: string
replies?: RawComment[]
}
type CommentsResponse = {
discussionId: string | null
discussionNumber: number | null
comments: RawComment[]
}
type Config = {
repo: string
repoId: string
category: string
categoryId: string
apiBase: string
mapping: string
baseUrl: string
}
type TextSegment = { node: Text; start: number; end: number }
const TOKEN_KEY = "inline-comments-gh-token"
const VIEWER_KEY = "inline-comments-gh-viewer"
const STATE_KEY = "inline-comments-oauth-state"
const ANCHOR_RE = /<!--\s*quartz-anchor:\s*(\{[\s\S]*?\})\s*-->/
const CONTEXT_LEN = 32
const GITHUB_GRAPHQL = "https://api.github.com/graphql"
// ─── config ─────────────────────────────────────────────────────────────
function readConfig(container: HTMLElement): Config {
const d = container.dataset
return {
repo: d.repo ?? "",
repoId: d.repoId ?? "",
category: d.category ?? "",
categoryId: d.categoryId ?? "",
apiBase: (d.apiBase ?? "").replace(/\/$/, ""),
mapping: d.mapping ?? "pathname",
baseUrl: d.baseUrl ?? "",
}
}
function getRoot(): HTMLElement | null {
return document.querySelector("article.popover-hint")
}
function getTerm(cfg: Config): string {
switch (cfg.mapping) {
case "title":
return document.title
case "url":
return location.origin + location.pathname
case "pathname":
default:
return location.pathname.replace(/\/+$/, "") || "/"
}
}
// ─── anchoring engine ───────────────────────────────────────────────────
// Collect the article's text nodes with their cumulative character offsets,
// skipping our own injected UI (badges) so offsets stay stable across renders.
function collectSegments(root: Node): { text: string; segments: TextSegment[] } {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node: Node): number {
const parent = (node as Text).parentElement
if (parent?.closest("[data-inline-comment-ignore]")) {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
},
})
let text = ""
const segments: TextSegment[] = []
let n: Node | null
while ((n = walker.nextNode())) {
const t = n as Text
const start = text.length
text += t.data
segments.push({ node: t, start, end: text.length })
}
return { text, segments }
}
// Character offset within the concatenated article text for a DOM boundary
// point. Works for both text-node and element containers.
function pointOffset(
segments: TextSegment[],
container: Node,
offsetInNode: number,
): number | null {
const probe = document.createRange()
try {
probe.setStart(container, offsetInNode)
probe.collapse(true)
} catch {
return null
}
let total = 0
for (const seg of segments) {
let endCmp: number
let startCmp: number
try {
endCmp = probe.comparePoint(seg.node, seg.node.data.length)
startCmp = probe.comparePoint(seg.node, 0)
} catch {
continue
}
if (endCmp <= 0) {
// whole segment lies before the boundary
total += seg.node.data.length
} else if (startCmp >= 0) {
// whole segment lies after the boundary — nothing to add
} else {
// boundary falls inside this segment
if (container === seg.node) {
total += offsetInNode
} else {
total += seg.node.data.length
}
}
}
return total
}
function nearestHeadingSlug(node: Node): string {
let el: Element | null =
node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement
while (el) {
const heading = el.matches?.("h1,h2,h3,h4,h5,h6")
? el
: el.previousElementSibling?.closest?.("h1,h2,h3,h4,h5,h6")
if (heading?.id) return heading.id
el = el.parentElement
}
const firstHeadingWithId = document.querySelector(
"article.popover-hint :is(h1,h2,h3,h4,h5,h6)[id]",
)
return firstHeadingWithId?.id ?? ""
}
function anchorFromRange(root: HTMLElement, range: Range): TextQuoteAnchor | null {
const { text, segments } = collectSegments(root)
const start = pointOffset(segments, range.startContainer, range.startOffset)
const end = pointOffset(segments, range.endContainer, range.endOffset)
if (start === null || end === null || end <= start) return null
const exact = text.slice(start, end)
if (!exact.trim()) return null
return {
v: 1,
exact,
prefix: text.slice(Math.max(0, start - CONTEXT_LEN), start),
suffix: text.slice(end, Math.min(text.length, end + CONTEXT_LEN)),
start,
end,
slug: nearestHeadingSlug(range.startContainer),
}
}
// Locate the anchor's text in the current article, disambiguating repeated
// matches by surrounding context and stored position. Returns null (→ orphan)
// if the exact text is no longer present.
function resolveAnchor(
text: string,
anchor: TextQuoteAnchor,
): { start: number; end: number } | null {
const { exact, prefix, suffix } = anchor
if (!exact) return null
const occurrences: number[] = []
let i = text.indexOf(exact)
while (i !== -1) {
occurrences.push(i)
i = text.indexOf(exact, i + 1)
}
if (occurrences.length === 0) return null
if (occurrences.length === 1) {
return { start: occurrences[0], end: occurrences[0] + exact.length }
}
let best = occurrences[0]
let bestScore = -Infinity
for (const o of occurrences) {
let score = 0
if (prefix) {
const pfx = text.slice(Math.max(0, o - prefix.length), o)
if (pfx.endsWith(prefix)) score += 2
else if (prefix.length >= 4 && pfx.endsWith(prefix.slice(-4))) score += 1
}
if (suffix) {
const sfx = text.slice(o + exact.length, o + exact.length + suffix.length)
if (sfx.startsWith(suffix)) score += 2
else if (suffix.length >= 4 && sfx.startsWith(suffix.slice(0, 4))) score += 1
}
const closer = Math.abs(o - anchor.start) < Math.abs(best - anchor.start)
if (score > bestScore || (score === bestScore && closer)) {
best = o
bestScore = score
}
}
return { start: best, end: best + exact.length }
}
// Wrap [start, end) of the article text in <mark> elements (one per crossed
// text node), tagging them with the group id and a comment count.
function highlightRange(
root: HTMLElement,
start: number,
end: number,
groupId: string,
count: number,
): HTMLElement[] {
const { segments } = collectSegments(root)
const marks: HTMLElement[] = []
for (const seg of segments) {
const s = Math.max(start, seg.start)
const e = Math.min(end, seg.end)
if (s >= e) continue
const range = document.createRange()
try {
range.setStart(seg.node, s - seg.start)
range.setEnd(seg.node, e - seg.start)
const mark = document.createElement("mark")
mark.className = "inline-comment-highlight"
mark.dataset.groupId = groupId
range.surroundContents(mark)
marks.push(mark)
} catch {
// range crosses an element boundary within this segment; skip it
}
}
if (count > 1 && marks.length > 0) {
const badge = document.createElement("span")
badge.className = "inline-comment-count"
badge.setAttribute("data-inline-comment-ignore", "")
badge.textContent = String(count)
marks[marks.length - 1].appendChild(badge)
}
return marks
}
// ─── comment body <-> anchor marker ─────────────────────────────────────
function parseAnchor(body: string): TextQuoteAnchor | null {
const m = body.match(ANCHOR_RE)
if (!m) return null
try {
const parsed = JSON.parse(m[1]) as TextQuoteAnchor
return typeof parsed.exact === "string" ? parsed : null
} catch {
return null
}
}
function formatBody(anchor: TextQuoteAnchor, prose: string): string {
const quote = anchor.exact
.split("\n")
.map((l) => "> " + l)
.join("\n")
const marker = `<!-- quartz-anchor: ${JSON.stringify(anchor)} -->`
return `${quote}\n\n${prose.trim()}\n\n${marker}`
}
// Strip the leading quote blockquote we injected so the thread UI doesn't
// duplicate the highlighted text. GitHub already drops the HTML comment.
function displayHTML(bodyHTML: string): string {
const tpl = document.createElement("template")
tpl.innerHTML = bodyHTML.trim()
const first = tpl.content.firstElementChild
if (first && first.tagName === "BLOCKQUOTE") first.remove()
return tpl.innerHTML
}
// ─── auth ───────────────────────────────────────────────────────────────
// A GitHub App issues user-to-server tokens that expire (8h by default) plus a
// refresh token (~6 months). If the App has expiration disabled, GitHub omits
// those fields and `expiresAt` stays null, meaning "never expires".
type Session = {
token: string
expiresAt: number | null
refreshToken: string | null
refreshExpiresAt: number | null
}
// Refresh this far before actual expiry so a request can't die mid-flight.
const EXPIRY_SKEW_MS = 60_000
function readSession(): Session | null {
const raw = localStorage.getItem(TOKEN_KEY)
if (!raw) return null
try {
const parsed = JSON.parse(raw) as Partial<Session>
if (typeof parsed.token !== "string") return null
return {
token: parsed.token,
expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null,
refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : null,
refreshExpiresAt:
typeof parsed.refreshExpiresAt === "number" ? parsed.refreshExpiresAt : null,
}
} catch {
// legacy format: a bare token string from the pre-GitHub-App version
return { token: raw, expiresAt: null, refreshToken: null, refreshExpiresAt: null }
}
}
function writeSession(s: Session): void {
localStorage.setItem(TOKEN_KEY, JSON.stringify(s))
}
function isExpired(at: number | null): boolean {
return at !== null && Date.now() >= at - EXPIRY_SKEW_MS
}
// The currently usable token, or null if absent/expired. Callers that can
// perform a refresh should use InlineComments#ensureToken instead.
function getToken(): string | null {
const s = readSession()
if (!s || isExpired(s.expiresAt)) return null
return s.token
}
// True when we hold a token that's merely expired but still refreshable —
// used to keep the composer showing "signed in" rather than flashing signed-out.
function canRefresh(): boolean {
const s = readSession()
return !!s?.refreshToken && !isExpired(s.refreshExpiresAt)
}
function isSignedIn(): boolean {
return getToken() !== null || canRefresh()
}
async function refreshSession(cfg: Config): Promise<string | null> {
const s = readSession()
if (!s?.refreshToken || isExpired(s.refreshExpiresAt)) return null
try {
const res = await fetch(`${cfg.apiBase}/api/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: s.refreshToken }),
})
if (!res.ok) {
clearToken()
return null
}
const next = (await res.json()) as Session
if (!next?.token) {
clearToken()
return null
}
writeSession(next)
return next.token
} catch {
return null
}
}
function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(VIEWER_KEY)
}
// The signed-in user, cached so the composer can show "signed in as @x"
// without a round-trip on every popover open.
function getCachedViewer(): CommentAuthor | null {
const raw = localStorage.getItem(VIEWER_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as CommentAuthor
} catch {
return null
}
}
async function fetchViewer(token: string): Promise<CommentAuthor | null> {
const cached = getCachedViewer()
if (cached) return cached
try {
const data = await graphql<{ viewer: CommentAuthor }>(
token,
`
query {
viewer {
login
avatarUrl
}
}
`,
{},
)
localStorage.setItem(VIEWER_KEY, JSON.stringify(data.viewer))
return data.viewer
} catch {
return null
}
}
function randomState(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID()
return Math.random().toString(36).slice(2) + Date.now().toString(36)
}
function login(cfg: Config): Promise<string> {
return new Promise((resolve, reject) => {
const state = randomState()
sessionStorage.setItem(STATE_KEY, state)
const apiOrigin = new URL(cfg.apiBase).origin
const loginUrl =
`${cfg.apiBase}/api/auth/login?state=${encodeURIComponent(state)}` +
`&origin=${encodeURIComponent(location.origin)}`
const popup = window.open(loginUrl, "inline-comments-login", "width=600,height=720")
if (!popup) {
reject(new Error("popup blocked"))
return
}
const onMessage = (e: MessageEvent) => {
if (e.origin !== apiOrigin) return
const data = e.data as Partial<Session> & { type?: string; state?: string }
if (data?.type !== "inline-comments-token") return
window.removeEventListener("message", onMessage)
if (data.state && data.state !== state) {
reject(new Error("oauth state mismatch"))
return
}
if (!data.token) {
reject(new Error("no token returned"))
return
}
writeSession({
token: data.token,
expiresAt: data.expiresAt ?? null,
refreshToken: data.refreshToken ?? null,
refreshExpiresAt: data.refreshExpiresAt ?? null,
})
resolve(data.token)
}
window.addEventListener("message", onMessage)
})
}
// ─── github i/o ─────────────────────────────────────────────────────────
async function fetchComments(cfg: Config, term: string): Promise<CommentsResponse> {
const empty: CommentsResponse = { discussionId: null, discussionNumber: null, comments: [] }
if (!cfg.apiBase) return empty
const token = getToken()
const url =
`${cfg.apiBase}/api/comments?repo=${encodeURIComponent(cfg.repo)}` +
`&category=${encodeURIComponent(cfg.category)}&term=${encodeURIComponent(term)}`
try {
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) return empty
return (await res.json()) as CommentsResponse
} catch {
return empty
}
}
async function graphql<T>(token: string, query: string, variables: object): Promise<T> {
const res = await fetch(GITHUB_GRAPHQL, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
})
if (res.status === 401) {
clearToken()
throw new Error("GitHub session expired — please sign in again.")
}
const json = (await res.json()) as { data?: T; errors?: { message: string }[] }
if (json.errors?.length) throw new Error(json.errors[0].message)
if (!json.data) throw new Error("empty GraphQL response")
return json.data
}
const CREATE_DISCUSSION = `
mutation ($repoId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
createDiscussion(input: { repositoryId: $repoId, categoryId: $categoryId, title: $title, body: $body }) {
discussion { id }
}
}`
const ADD_COMMENT = `
mutation ($discussionId: ID!, $body: String!, $replyToId: ID) {
addDiscussionComment(input: { discussionId: $discussionId, body: $body, replyToId: $replyToId }) {
comment { id url createdAt bodyHTML body author { login avatarUrl } }
}
}`
async function ensureDiscussion(
token: string,
cfg: Config,
term: string,
current: CommentsResponse,
): Promise<string> {
if (current.discussionId) return current.discussionId
const data = await graphql<{ createDiscussion: { discussion: { id: string } } }>(
token,
CREATE_DISCUSSION,
{
repoId: cfg.repoId,
categoryId: cfg.categoryId,
title: term,
body: `Comments for \`${term}\``,
},
)
return data.createDiscussion.discussion.id
}
async function addComment(
token: string,
discussionId: string,
body: string,
replyToId?: string,
): Promise<RawComment> {
const data = await graphql<{ addDiscussionComment: { comment: RawComment } }>(
token,
ADD_COMMENT,
{
discussionId,
body,
replyToId: replyToId ?? null,
},
)
return data.addDiscussionComment.comment
}
// ─── floating UI helpers ────────────────────────────────────────────────
type Cleanup = () => void
function anchorFloating(
reference: HTMLElement | { getBoundingClientRect: () => DOMRect },
floating: HTMLElement,
): Cleanup {
return autoUpdate(reference as HTMLElement, floating, () => {
computePosition(reference as HTMLElement, floating, {
strategy: "fixed",
placement: "top",
middleware: [offset(8), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
Object.assign(floating.style, { left: `${x}px`, top: `${y}px` })
})
})
}
function virtualRef(range: Range) {
return { getBoundingClientRect: () => range.getBoundingClientRect() }
}
// ─── main ───────────────────────────────────────────────────────────────
function relTime(iso: string): string {
const then = new Date(iso).getTime()
const secs = Math.round((Date.now() - then) / 1000)
const units: [number, Intl.RelativeTimeFormatUnit][] = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[30, "day"],
[12, "month"],
[Number.POSITIVE_INFINITY, "year"],
]
let value = secs
let unit: Intl.RelativeTimeFormatUnit = "second"
for (const [size, u] of units) {
if (Math.abs(value) < size) {
unit = u
break
}
value = Math.round(value / size)
unit = u
}
return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(-value, unit)
}
function commentItemEl(c: RawComment, isReply: boolean): HTMLElement {
const item = document.createElement("div")
item.className = isReply ? "inline-comment-item reply" : "inline-comment-item"
const meta = document.createElement("div")
meta.className = "inline-comment-meta"
const avatar = document.createElement("img")
avatar.src = c.author?.avatarUrl ?? ""
avatar.alt = c.author?.login ?? ""
const author = document.createElement("a")
author.className = "inline-comment-author"
author.textContent = c.author?.login ?? "unknown"
author.href = c.url
author.target = "_blank"
author.rel = "noopener noreferrer"
const date = document.createElement("span")
date.className = "inline-comment-date"
date.textContent = relTime(c.createdAt)
meta.append(avatar, author, date)
const body = document.createElement("div")
body.className = "inline-comment-body"
body.innerHTML = displayHTML(c.bodyHTML)
item.append(meta, body)
return item
}
class InlineComments {
private cfg: Config
private root: HTMLElement
private term: string
private state: CommentsResponse = { discussionId: null, discussionNumber: null, comments: [] }
private popover: HTMLElement | null = null
private popoverCleanups: Cleanup[] = []
// groupId -> comments sharing an identical resolved range
private groups = new Map<string, RawComment[]>()
constructor(cfg: Config, root: HTMLElement) {
this.cfg = cfg
this.root = root
this.term = getTerm(cfg)
}
// torn down by quartz's SPA router on the next navigation
registerCleanup(fn: Cleanup) {
window.addCleanup(fn)
}
// Returns a usable token: the current one, else a silent refresh, else the
// sign-in popup.
//
// `login()` must be reached synchronously from the originating click or the
// browser blocks the popup. A refresh is an await, so it is only attempted
// when we actually hold a refresh token — otherwise we go straight to
// login() with no await in front of it.
private async ensureToken(): Promise<string> {
const existing = getToken()
if (existing) return existing
if (canRefresh()) {
const refreshed = await refreshSession(this.cfg)
if (refreshed) return refreshed
// refresh failed — fall through to sign-in. The popup may be blocked
// here since we've now awaited; the error surfaces in the composer and
// a second click (with no refresh token left) opens it cleanly.
}
const token = await login(this.cfg)
await fetchViewer(token)
return token
}
async start() {
this.setupSelectionUI()
await this.render()
}
// ── rendering existing comments ──
async render() {
this.state = await fetchComments(this.cfg, this.term)
this.groups.clear()
const { text } = collectSegments(this.root)
type Placed = { key: string; start: number; end: number; comments: RawComment[] }
const placedByRange = new Map<string, Placed>()
const orphans: RawComment[] = []
for (const c of this.state.comments) {
const anchor = parseAnchor(c.body)
if (!anchor) continue // page-level comment → left to the giscus widget
const resolved = resolveAnchor(text, anchor)
if (!resolved) {
orphans.push(c)
continue
}
const key = `${resolved.start}:${resolved.end}`
const existing = placedByRange.get(key)
if (existing) existing.comments.push(c)
else placedByRange.set(key, { key, start: resolved.start, end: resolved.end, comments: [c] })
}
// wrap ranges last-first so earlier offsets are not invalidated
const placed = [...placedByRange.values()].sort((a, b) => b.start - a.start)
for (const p of placed) {
this.groups.set(p.key, p.comments)
const marks = highlightRange(this.root, p.start, p.end, p.key, p.comments.length)
for (const mark of marks) {
mark.addEventListener("click", (e) => {
e.preventDefault()
this.openThread(p.key, mark)
})
}
}
this.renderOrphans(orphans)
}
renderOrphans(orphans: RawComment[]) {
document.querySelector(".inline-comments-orphans")?.remove()
if (orphans.length === 0) return
const wrap = document.createElement("section")
wrap.className = "inline-comments-orphans"
const h = document.createElement("h3")
h.textContent = `Comments on earlier revisions (${orphans.length})`
wrap.appendChild(h)
for (const c of orphans) wrap.appendChild(commentItemEl(c, false))
this.root.after(wrap)
this.registerCleanup(() => wrap.remove())
}
// ── selection → add button ──
setupSelectionUI() {
const btn = document.createElement("button")
btn.className = "inline-comment-add"
btn.setAttribute("data-inline-comment-ignore", "")
btn.innerHTML =
`<svg viewBox="0 0 24 24" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>` +
`<span>Comment</span>`
btn.style.left = "0"
btn.style.top = "0"
document.body.appendChild(btn)
this.registerCleanup(() => btn.remove())
let pendingRange: Range | null = null
let btnCleanup: Cleanup | null = null
const hide = () => {
btn.classList.remove("visible")
btnCleanup?.()
btnCleanup = null
}
const onSelect = () => {
const sel = window.getSelection()
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return hide()
const range = sel.getRangeAt(0)
if (!this.root.contains(range.commonAncestorContainer) || !range.toString().trim()) {
return hide()
}
pendingRange = range.cloneRange()
btnCleanup?.()
btnCleanup = anchorFloating(virtualRef(range), btn)
btn.classList.add("visible")
}
const onDocMouseDown = (e: MouseEvent) => {
if (e.target instanceof Node && (btn.contains(e.target) || this.popover?.contains(e.target)))
return
// let the browser update the selection first
setTimeout(() => {
const sel = window.getSelection()
if (!sel || sel.isCollapsed) hide()
}, 0)
}
btn.addEventListener("mousedown", (e) => e.preventDefault())
btn.addEventListener("click", () => {
if (pendingRange) this.openComposer(pendingRange)
hide()
})
document.addEventListener("mouseup", onSelect)
document.addEventListener("mousedown", onDocMouseDown)
this.registerCleanup(() => {
document.removeEventListener("mouseup", onSelect)
document.removeEventListener("mousedown", onDocMouseDown)
btnCleanup?.()
})
}
// ── popover plumbing ──
private closePopover() {
this.popoverCleanups.forEach((fn) => fn())
this.popoverCleanups = []
this.popover?.remove()
this.popover = null
}
private openPopover(
reference: HTMLElement | { getBoundingClientRect: () => DOMRect },
build: (el: HTMLElement) => void,
) {
this.closePopover()
const el = document.createElement("div")
el.className = "inline-comment-popover"
el.setAttribute("data-inline-comment-ignore", "")
build(el)
document.body.appendChild(el)
this.popover = el
this.popoverCleanups.push(anchorFloating(reference, el))
const onOutside = (e: MouseEvent) => {
if (e.target instanceof Node && !el.contains(e.target)) this.closePopover()
}
setTimeout(() => document.addEventListener("mousedown", onOutside), 0)
this.popoverCleanups.push(() => document.removeEventListener("mousedown", onOutside))
// ensure the popover is also cleaned up if the user navigates away
this.registerCleanup(() => this.closePopover())
}
private quoteEl(exact: string): HTMLElement {
const q = document.createElement("blockquote")
q.className = "inline-comment-quote"
q.textContent = exact
return q
}
private composerEl(
submitLabel: string,
onSubmit: (text: string, setBusy: (b: boolean) => void) => void,
): HTMLElement {
const composer = document.createElement("div")
composer.className = "inline-comment-composer"
const textarea = document.createElement("textarea")
textarea.placeholder = "Add a comment…"
const actions = document.createElement("div")
actions.className = "inline-comment-actions"
const hint = document.createElement("span")
hint.className = "inline-comment-hint"
const submit = document.createElement("button")
submit.className = "inline-comment-submit"
submit.textContent = submitLabel
submit.disabled = true
// auth row — sign-in is offered here rather than only at the page bottom
const auth = document.createElement("div")
auth.className = "inline-comment-auth"
const setBusy = (b: boolean) => {
submit.disabled = b || textarea.value.trim().length === 0
submit.textContent = b ? "Posting…" : submitLabel
}
const showHint = (msg: string) => (hint.textContent = msg)
const renderAuth = () => {
auth.replaceChildren()
// an expired-but-refreshable session still counts as signed in, so the
// row doesn't flash "signed out" every 8 hours
if (!isSignedIn()) {
const signIn = document.createElement("button")
signIn.className = "inline-comment-signin"
signIn.type = "button"
signIn.innerHTML =
`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>` +
`<span>Sign in with GitHub</span>`
signIn.addEventListener("click", async () => {
// must call login() synchronously in the click handler or the
// popup gets blocked
try {
signIn.disabled = true
const t = await login(this.cfg)
await fetchViewer(t)
showHint("")
renderAuth()
textarea.focus()
} catch (err) {
signIn.disabled = false
showHint(err instanceof Error ? err.message : "Sign-in failed.")
}
})
auth.append(signIn)
return
}
const who = document.createElement("span")
who.className = "inline-comment-whoami"
const viewer = getCachedViewer()
if (viewer) {
const avatar = document.createElement("img")
avatar.src = viewer.avatarUrl
avatar.alt = viewer.login
who.append(avatar, document.createTextNode(`@${viewer.login}`))
} else {
who.textContent = "Signed in"
// resolve the login lazily, then re-render. Only possible with a live
// token; if it's merely refreshable we'll fill this in after the next
// refresh rather than burning a round-trip here.
const live = getToken()
if (live) void fetchViewer(live).then((v) => v && renderAuth())
}
const signOut = document.createElement("button")
signOut.className = "inline-comment-signout"
signOut.type = "button"
signOut.textContent = "Sign out"
signOut.addEventListener("click", () => {
clearToken()
renderAuth()
})
auth.append(who, signOut)
}
textarea.addEventListener("input", () => {
submit.disabled = textarea.value.trim().length === 0
})
submit.addEventListener("click", () => {
const text = textarea.value.trim()
if (text) onSubmit(text, setBusy)
})
;(composer as HTMLElement & { showHint?: (m: string) => void }).showHint = showHint
;(composer as HTMLElement & { refreshAuth?: () => void }).refreshAuth = renderAuth
renderAuth()
actions.append(hint, submit)
composer.append(textarea, auth, actions)
return composer
}
// ── new comment on a fresh selection ──
private openComposer(range: Range) {
const anchor = anchorFromRange(this.root, range)
if (!anchor) return
this.openPopover(virtualRef(range), (el) => {
el.appendChild(this.quoteEl(anchor.exact))
const composer = this.composerEl("Comment", async (text, setBusy) => {
setBusy(true)
try {
const token = await this.ensureToken()
const discussionId = await ensureDiscussion(token, this.cfg, this.term, this.state)
this.state.discussionId = discussionId
await addComment(token, discussionId, formatBody(anchor, text))
this.closePopover()
await this.render()
} catch (err) {
setBusy(false)
const showHint = (composer as HTMLElement & { showHint?: (m: string) => void }).showHint
showHint?.(err instanceof Error ? err.message : "Failed to post comment.")
}
})
el.appendChild(composer)
})
}
// ── existing thread on a highlight ──
private openThread(groupId: string, mark: HTMLElement) {
const comments = this.groups.get(groupId) ?? []
if (comments.length === 0) return
const anchor = parseAnchor(comments[0].body)
this.openPopover(mark, (el) => {
if (anchor) el.appendChild(this.quoteEl(anchor.exact))
const thread = document.createElement("div")
thread.className = "inline-comment-thread"
for (const c of comments) {
thread.appendChild(commentItemEl(c, false))
for (const r of c.replies ?? []) thread.appendChild(commentItemEl(r, true))
}
el.appendChild(thread)
const replyTo = comments[0]
const composer = this.composerEl("Reply", async (text, setBusy) => {
setBusy(true)
try {
const token = await this.ensureToken()
const discussionId = this.state.discussionId
if (!discussionId) throw new Error("missing discussion")
await addComment(token, discussionId, text, replyTo.id)
this.closePopover()
await this.render()
} catch (err) {
setBusy(false)
const showHint = (composer as HTMLElement & { showHint?: (m: string) => void }).showHint
showHint?.(err instanceof Error ? err.message : "Failed to post reply.")
}
})
el.appendChild(composer)
})
}
}
// ─── lifecycle ──────────────────────────────────────────────────────────
document.addEventListener("nav", () => {
const container = document.querySelector(".inline-comments") as HTMLElement | null
const root = getRoot()
if (!container || !root) return
const cfg = readConfig(container)
if (!cfg.apiBase) return // graceful no-op until the worker is deployed
const app = new InlineComments(cfg, root)
void app.start()
})