diff --git a/static/app.js b/static/app.js index 05e6252..2468858 100644 --- a/static/app.js +++ b/static/app.js @@ -1,6 +1,7 @@ import * as pdfjsLib from "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.mjs"; import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.es.mjs"; import { marked } from "https://cdn.jsdelivr.net/npm/marked@15.0.12/lib/marked.esm.js"; +import { linkifyChatPageReferences } from "./chat_references.js"; pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.worker.min.mjs"; @@ -2414,6 +2415,56 @@ function jumpToPage(pageNumber) { } } +function pageReferenceSelection(pageNumber) { + const pageSize = pageSizeFor(pageNumber); + if (pageSize?.width && pageSize?.height) { + const marginX = Math.max(8, pageSize.width * 0.025); + const marginY = Math.max(8, pageSize.height * 0.025); + return { + pageNumber, + rects: [[ + marginX, + marginY, + Math.max(marginX + 4, pageSize.width - marginX), + Math.max(marginY + 4, pageSize.height - marginY), + ]], + }; + } + + const page = els.pdfViewer?.querySelector(`[data-page-number="${pageNumber}"]`); + if (!page) { + return { pageNumber }; + } + + const inset = 10; + return { + pageNumber, + previewRects: [[ + inset, + inset, + Math.max(inset + 4, page.clientWidth - inset), + Math.max(inset + 4, page.clientHeight - inset), + ]], + }; +} + +function jumpToChatPageReference(pageNumber) { + if (!pageNumber) { + return; + } + const page = els.pdfViewer?.querySelector(`[data-page-number="${pageNumber}"]`); + if (!page) { + return; + } + + jumpToPage(pageNumber); + showSelectionPreview(pageReferenceSelection(pageNumber), { + className: "chat-page-reference-rect", + flash: true, + durationMs: 2200, + }); +} + function highlightsByPage(highlights) { const map = new Map(); for (const [index, highlight] of (highlights || []).entries()) { @@ -2905,7 +2956,7 @@ function renderChat() { const body = document.createElement("div"); body.className = "chat-markdown"; - renderChatMarkdown(body, message.content); + renderChatMarkdown(body, message.content, { linkPageReferences: role === "assistant" }); article.append(label, body); els.chatMessages.appendChild(article); @@ -2914,7 +2965,7 @@ function renderChat() { els.chatMessages.scrollTop = els.chatMessages.scrollHeight; } -function renderChatMarkdown(target, value) { +function renderChatMarkdown(target, value, options = {}) { const unsafeHtml = marked.parse(String(value || "")); target.innerHTML = DOMPurify.sanitize(unsafeHtml, { ALLOWED_TAGS: [ @@ -2953,6 +3004,33 @@ function renderChatMarkdown(target, value) { link.target = "_blank"; link.rel = "noreferrer"; }); + + if (!options.linkPageReferences) { + return; + } + linkifyChatPageReferences(target, { pageCount: currentPageCount() }); + target.querySelectorAll("[data-chat-page-reference]").forEach((button) => { + button.addEventListener("click", () => { + jumpToChatPageReference(Number(button.dataset.chatPageReference)); + }); + }); +} + +function currentPageCount() { + const pageNumbers = []; + for (const page of state.selectedPaper?.page_sizes || []) { + const pageNumber = Number(page.page_number); + if (pageNumber) { + pageNumbers.push(pageNumber); + } + } + els.pdfViewer?.querySelectorAll(".pdf-page[data-page-number]").forEach((page) => { + const pageNumber = Number(page.dataset.pageNumber); + if (pageNumber) { + pageNumbers.push(pageNumber); + } + }); + return pageNumbers.length ? Math.max(...pageNumbers) : 0; } function pageSizeFor(pageNumber) { @@ -3127,6 +3205,7 @@ function showSelectionPreview(selection, options = {}) { return; } + const previewNodes = []; for (const rect of selectionPreviewRects(selection, page)) { const [x0, y0, x1, y1] = rect; const node = document.createElement("div"); @@ -3140,6 +3219,13 @@ function showSelectionPreview(selection, options = {}) { node.style.width = `${Math.max(4, x1 - x0)}px`; node.style.height = `${Math.max(4, y1 - y0)}px`; overlay.appendChild(node); + previewNodes.push(node); + } + + if (options.durationMs) { + window.setTimeout(() => { + previewNodes.forEach((node) => node.remove()); + }, options.durationMs); } } diff --git a/static/chat_references.js b/static/chat_references.js new file mode 100644 index 0000000..157b431 --- /dev/null +++ b/static/chat_references.js @@ -0,0 +1,98 @@ +const PAGE_REFERENCE_PATTERN = /\b(?:p(?:g|age)?|pages?)\.?\s*(\d{1,4})\b/gi; +const SKIP_REFERENCE_TAGS = new Set(["A", "BUTTON", "CODE", "PRE", "SCRIPT", "STYLE", "TEXTAREA"]); + +function validPageNumber(rawPageNumber, pageCount = 0) { + const pageNumber = Number.parseInt(rawPageNumber, 10); + const maxPageNumber = Number(pageCount) || 0; + if (!Number.isInteger(pageNumber) || pageNumber < 1) { + return null; + } + if (maxPageNumber && pageNumber > maxPageNumber) { + return null; + } + return pageNumber; +} + +export function chatPageReferenceSegments(value, pageCount = 0) { + const text = String(value || ""); + const segments = []; + let cursor = 0; + + for (const match of text.matchAll(PAGE_REFERENCE_PATTERN)) { + const pageNumber = validPageNumber(match[1], pageCount); + if (!pageNumber) { + continue; + } + + if (match.index > cursor) { + segments.push({ text: text.slice(cursor, match.index) }); + } + segments.push({ text: match[0], pageNumber }); + cursor = match.index + match[0].length; + } + + if (!segments.length) { + return [{ text }]; + } + if (cursor < text.length) { + segments.push({ text: text.slice(cursor) }); + } + return segments; +} + +function shouldSkipTextNode(node) { + const parent = node.parentElement; + return !parent || Boolean(parent.closest(Array.from(SKIP_REFERENCE_TAGS).join(","))); +} + +function pageReferenceButton(documentRef, segment) { + const button = documentRef.createElement("button"); + button.className = "chat-page-reference"; + button.type = "button"; + button.dataset.chatPageReference = String(segment.pageNumber); + button.textContent = segment.text; + button.title = `Jump to page ${segment.pageNumber}`; + button.setAttribute("aria-label", `Jump to page ${segment.pageNumber}`); + return button; +} + +export function linkifyChatPageReferences(root, options = {}) { + if (!root) { + return 0; + } + + const documentRef = root.ownerDocument || globalThis.document; + if (!documentRef) { + return 0; + } + + const textNodes = []; + const showText = documentRef.defaultView?.NodeFilter?.SHOW_TEXT || globalThis.NodeFilter?.SHOW_TEXT || 4; + const walker = documentRef.createTreeWalker(root, showText); + while (walker.nextNode()) { + if (!shouldSkipTextNode(walker.currentNode)) { + textNodes.push(walker.currentNode); + } + } + + let referenceCount = 0; + for (const node of textNodes) { + const segments = chatPageReferenceSegments(node.nodeValue || "", options.pageCount || 0); + if (!segments.some((segment) => segment.pageNumber)) { + continue; + } + + const fragment = documentRef.createDocumentFragment(); + for (const segment of segments) { + if (segment.pageNumber) { + fragment.appendChild(pageReferenceButton(documentRef, segment)); + referenceCount += 1; + } else if (segment.text) { + fragment.appendChild(documentRef.createTextNode(segment.text)); + } + } + node.replaceWith(fragment); + } + + return referenceCount; +} diff --git a/static/styles.css b/static/styles.css index 5d8826a..3ed6706 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1077,6 +1077,11 @@ h2 { outline-color: var(--accent-strong); } +.chat-page-reference-rect { + background: rgba(255, 211, 109, 0.18); + outline: 2px solid var(--amber); +} + @keyframes takeaway-flash { 0% { background: rgba(255, 211, 109, 0.42); @@ -1699,6 +1704,27 @@ h2 { word-break: break-word; } +.chat-page-reference { + display: inline; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--accent-text); + padding: 0 2px; + font: inherit; + font-weight: 700; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; + word-break: normal; +} + +.chat-page-reference:hover, +.chat-page-reference:focus-visible { + background: var(--accent-soft); + outline: 1px solid var(--citation-outline); +} + .chat-form { display: grid; grid-template-columns: minmax(0, 1fr) 54px 104px; diff --git a/templates/index.html b/templates/index.html index b5197c7..9f6e4bc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10,7 +10,7 @@ href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700&display=swap" rel="stylesheet" /> - +