Merge pull request #795 from christianlouis/copilot/fix-annotations-saving

feat(ui): Connect EmbedPDF viewer to annotations panel with bidirectional sync
This commit is contained in:
Christian Krakau-Louis
2026-03-22 16:23:45 +01:00
committed by GitHub
4 changed files with 159 additions and 5 deletions
+14 -2
View File
@@ -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 = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
(_i18n.page || 'Page') + ' ' + ann.page;
pageInfo.addEventListener('click', function () {
if (typeof window._embedpdfScrollToPage === 'function') {
window._embedpdfScrollToPage(ann.page);
}
});
header.appendChild(typeBadge);
if (ann.color) {
+101 -3
View File
@@ -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 () {});
}
}
</script>
{% endif %}
{% endblock %}
+1
View File
@@ -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",
+43
View File
@@ -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)