diff --git a/frontend/static/js/annotations.js b/frontend/static/js/annotations.js index 35d0ede2..7875769f 100644 --- a/frontend/static/js/annotations.js +++ b/frontend/static/js/annotations.js @@ -24,6 +24,10 @@ _i18n = i18n || {}; _loadAnnotations(); + // Expose reload function so the EmbedPDF viewer init script can refresh the + // list after auto-saving an annotation created inside the viewer. + window._reloadAnnotations = _loadAnnotations; + var form = document.getElementById('annotation-form'); if (form) { form.addEventListener('submit', function (e) { @@ -81,10 +85,18 @@ typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type; typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type; - var pageInfo = document.createElement('span'); - pageInfo.className = 'annotation-page'; + var pageInfo = document.createElement('button'); + pageInfo.type = 'button'; + pageInfo.className = 'annotation-page annotation-page--link'; + pageInfo.setAttribute('aria-label', (_i18n.go_to_page || 'Go to page') + ' ' + ann.page); + pageInfo.title = (_i18n.go_to_page || 'Go to page') + ' ' + ann.page; pageInfo.innerHTML = ' ' + (_i18n.page || 'Page') + ' ' + ann.page; + pageInfo.addEventListener('click', function () { + if (typeof window._embedpdfScrollToPage === 'function') { + window._embedpdfScrollToPage(ann.page); + } + }); header.appendChild(typeBadge); if (ann.color) { diff --git a/frontend/templates/file_annotations.html b/frontend/templates/file_annotations.html index d1fc3411..63cb4c3f 100644 --- a/frontend/templates/file_annotations.html +++ b/frontend/templates/file_annotations.html @@ -380,6 +380,23 @@ font-size: 0.75rem; color: #718096; } + .annotation-page--link { + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: 0.75rem; + color: #718096; + text-decoration: none; + min-height: 0; + } + .annotation-page--link:hover { + color: #3182ce; + text-decoration: underline; + } + .dark .annotation-page--link:hover { + color: #63b3ed; + } .annotation-content { color: #4a5568; font-size: 0.9375rem; @@ -750,6 +767,7 @@ delete_confirm: {{ _("annotations.delete_confirm") | tojson }}, page: {{ _("annotations.page") | tojson }}, color: {{ _("annotations.color") | tojson }}, + go_to_page: {{ _("annotations.go_to_page") | tojson }}, type_note: {{ _("annotations.type_note") | tojson }}, type_highlight: {{ _("annotations.type_highlight") | tojson }}, type_underline: {{ _("annotations.type_underline") | tojson }}, @@ -801,18 +819,98 @@ const viewerEl = document.getElementById('embedpdf-viewer'); if (viewerEl) { + const fileId = {{ file.id | tojson }}; {% if processed_file_exists %} - const pdfUrl = '/api/files/{{ file.id }}/preview?version=processed'; + const pdfUrl = '/api/files/' + fileId + '/preview?version=processed'; {% else %} - const pdfUrl = '/api/files/{{ file.id }}/preview?version=original'; + const pdfUrl = '/api/files/' + fileId + '/preview?version=original'; {% endif %} - EmbedPDF.init({ + const viewer = EmbedPDF.init({ type: 'container', target: viewerEl, src: pdfUrl, }); + + if (viewer) { + viewer.registry.then(function (registry) { + // ── Page sync: viewer page change → update annotation form ────────── + var scrollPlugin = registry.getPlugin('scroll'); + if (scrollPlugin) { + var scroll = scrollPlugin.provides(); + scroll.onPageChange(function (event) { + var pageInput = document.getElementById('annotation-page-input'); + if (pageInput) { + pageInput.value = String(event.pageNumber); + } + }); + // Expose scrollToPage so the annotations panel can navigate the viewer + window._embedpdfScrollToPage = function (pageNumber) { + scroll.scrollToPage({ pageNumber: pageNumber }); + }; + } + + // ── Auto-save: viewer annotation events → DocuElevate API ─────────── + var annotationPlugin = registry.getPlugin('annotation'); + if (annotationPlugin) { + var annotation = annotationPlugin.provides(); + annotation.onAnnotationEvent(function (event) { + if (event.type !== 'create') return; + var ann = event.annotation; + var pageIndex = typeof ann.pageIndex === 'number' ? ann.pageIndex + : (typeof event.pageIndex === 'number' ? event.pageIndex : 0); + var page = pageIndex + 1; + var rect = ann.rect || { x: 0, y: 0, width: 0, height: 0 }; + var color = ann.strokeColor || ann.color || undefined; + var content = (ann.contents || '').trim(); + // Map PDF annotation subtypes to DocuElevate annotation types + var typeMap = { + highlight: 'highlight', + underline: 'underline', + strikeout: 'strikethrough', + squiggly: 'underline', + text: 'note', + freetext: 'note', + ink: 'note', + square: 'note', + circle: 'note', + }; + var annType = typeMap[String(ann.type).toLowerCase()] || 'note'; + if (!content) { + var typeLabel = annType.charAt(0).toUpperCase() + annType.slice(1); + content = typeLabel + ' \u2014 p.' + page; + } + var payload = { + page: page, + x: rect.x || 0, + y: rect.y || 0, + width: rect.width || 0, + height: rect.height || 0, + annotation_type: annType, + content: content, + }; + if (color) { + payload.color = color; + } + fetch('/api/files/' + fileId + '/annotations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (r) { + if (r.ok && typeof window._reloadAnnotations === 'function') { + window._reloadAnnotations(); + } + }) + .catch(function (err) { + console.error('Failed to save viewer annotation:', err); + }); + }); + } + }).catch(function () {}); + } } {% endif %} {% endblock %} + diff --git a/frontend/translations/en.json b/frontend/translations/en.json index 42d3ffe6..fbdc7939 100644 --- a/frontend/translations/en.json +++ b/frontend/translations/en.json @@ -321,6 +321,7 @@ "annotations.delete_confirm": "Are you sure you want to delete this annotation?", "annotations.deleted": "Annotation deleted", "annotations.empty": "No annotations yet", + "annotations.go_to_page": "Go to page", "annotations.heading": "Annotations", "annotations.page": "Page", "annotations.save": "Save", diff --git a/tests/test_comments_ui.py b/tests/test_comments_ui.py index 059610a5..27b68017 100644 --- a/tests/test_comments_ui.py +++ b/tests/test_comments_ui.py @@ -156,6 +156,49 @@ class TestCommentsUIRendering: assert 'id="embedpdf-viewer"' in html assert "@embedpdf/snippet" in html + def test_embedpdf_init_subscribes_to_page_change(self, client: TestClient, db_session, tmp_path): + """The EmbedPDF init script should subscribe to page change events to sync the form.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + # Verifies the viewer registry is awaited and scroll plugin is used + assert "viewer.registry" in html + assert "onPageChange" in html + assert "annotation-page-input" in html + + def test_embedpdf_init_exposes_scroll_function(self, client: TestClient, db_session, tmp_path): + """The EmbedPDF init script must expose _embedpdfScrollToPage for the annotations panel.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert "_embedpdfScrollToPage" in resp.text + assert "scrollToPage" in resp.text + + def test_embedpdf_init_saves_viewer_annotations(self, client: TestClient, db_session, tmp_path): + """The EmbedPDF init script should capture annotation events and POST to the API.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + html = resp.text + assert "onAnnotationEvent" in html + # Verifies the POST target is the annotations API for this file + assert "/api/files/" in html and "/annotations" in html + + def test_embedpdf_init_reloads_annotation_list(self, client: TestClient, db_session, tmp_path): + """After auto-saving a viewer annotation, the panel list should be refreshed.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert "_reloadAnnotations" in resp.text + + def test_annotations_page_has_go_to_page_i18n(self, client: TestClient, db_session, tmp_path): + """The annotations i18n bundle should include the go_to_page key.""" + f = _create_file(db_session, tmp_path) + resp = client.get(f"/files/{f.id}/annotations") + assert resp.status_code == 200 + assert "go_to_page" in resp.text + def test_summary_page_renders(self, client: TestClient, db_session, tmp_path): """The summary page at /files/{id} should render correctly.""" f = _create_file(db_session, tmp_path)