browser/src/canvas/sections/CommentListSection.ts | 125 +++++
browser/src/layer/tile/ImpressTileLayer.js | 13
cypress_test/integration_tests/desktop/draw/annotation_spec.js | 237 ++++++++++
3 files changed, 370 insertions(+), 5 deletions(-)
New commits:
commit 8b0a148f8596313e3a8ae88a435784f20449abb1
Author: Mike Kaganski <
mike.k...@collabora.com>
AuthorDate: Tue Apr 28 18:30:13 2026 +0500
Commit: Miklos Vajna <
vmi...@collabora.com>
CommitDate: Wed Apr 29 09:18:13 2026 +0000
PDF comments: click-to-place anchor for newly inserted comments
Previously, adding comments to PDF placed them at the page's top left.
Instead, defer creating a new comment in PDF until the user picks a spot
on the page. Insert Comment now enters a placement mode; the captured
click is mapped to document twips and used both as the on-screen anchor
for the editor and as the X/Y args of .uno:InsertAnnotation. Esc cancels.
Pairs with the engine commit I07cd212eef0f5f79d05dd7be5f7e8c1bcaa5f857
"sd: implement placing comments in specified position" which added
PositionX/PositionY to the slot.
Signed-off-by: Mike Kaganski <
mike.k...@collabora.com>
Change-Id: I9d7bbb88bc2b1f8401c9a732a9c18c002724f117
Reviewed-on:
https://gerrit.collaboraoffice.com/c/online/+/1777
diff --git a/browser/src/canvas/sections/CommentListSection.ts b/browser/src/canvas/sections/CommentListSection.ts
index 8fc6b423e073..3d30066488db 100644
--- a/browser/src/canvas/sections/CommentListSection.ts
+++ b/browser/src/canvas/sections/CommentListSection.ts
@@ -47,14 +47,20 @@ window.L.Map.include({
if (author in this._viewInfoByUserName) {
avatar = this._viewInfoByUserName[author].userextrainfo.avatar;
}
- this._docLayer.newAnnotation({
+ const commentData = {
text: '',
textrange: '',
author: author,
dateTime: new Date().toISOString(),
id: 'new', // 'new' only when added by us
avatar: avatar
- });
+ };
+ // In PDF, enter a click-to-place mode and let the user pick the spot.
+ if (app.file.fileBasedView && commentSection) {
+ commentSection.startCommentPlacement(commentData);
+ return;
+ }
+ this._docLayer.newAnnotation(commentData);
},
insertThreadedComment: function() {
@@ -161,6 +167,12 @@ export class CommentSection extends CanvasSectionObject {
private annotationMaxSize: number;
escapeListener: (e: KeyboardEvent) => void;
+ // Active "click anywhere on the page to place a new comment" mode for PDF.
+ // When non-null, the next document mousedown is consumed to position a new
+ // comment at that point instead of being forwarded to core.
+ private placementCommentData: any = null;
+ private placementSavedCursor: string | null = null;
+
constructor () {
super(
app.CSections.CommentList.name);
@@ -195,6 +207,12 @@ export class CommentSection extends CanvasSectionObject {
this.mobileCommentModalId = this.map.uiManager.generateModalId(this.mobileCommentId);
this.annotationMinSize = Number(getComputedStyle(document.documentElement).getPropertyValue('--annotation-min-size'));
this.annotationMaxSize = Number(getComputedStyle(document.documentElement).getPropertyValue('--annotation-max-size'));
+
+ // PDF placement-mode handlers are added to / removed from the DOM via
+ // addEventListener/removeEventListener; bind once here so the same
+ // reference is used for both calls.
+ this.onPlacementMouseDown = this.onPlacementMouseDown.bind(this);
+ this.onPlacementKeyDown = this.onPlacementKeyDown.bind(this);
}
public onInitialize (): void {
@@ -787,6 +805,91 @@ export class CommentSection extends CanvasSectionObject {
app.map.fire('deleteannotation');
}
+ public startCommentPlacement(commentData: any): void {
+ // An additional Insert Comment trigger while placement is already
+ // pending is a no-op.
+ if (this.placementCommentData)
+ return;
+
+ const canvas = document.getElementById('document-canvas') as HTMLCanvasElement | null;
+ if (!canvas)
+ return;
+
+ this.placementCommentData = commentData;
+ this.placementSavedCursor = canvas.style.cursor;
+ canvas.style.cursor = 'crosshair';
+
+ // Capture phase, before CanvasSectionContainer's onmousedown property
+ // handler dispatches to MouseControl/etc.
+ canvas.addEventListener('mousedown', this.onPlacementMouseDown, true);
+ document.addEventListener('keydown', this.onPlacementKeyDown, true);
+ }
+
+ private exitCommentPlacement(): void {
+ const canvas = document.getElementById('document-canvas') as HTMLCanvasElement | null;
+ if (canvas) {
+ canvas.removeEventListener('mousedown', this.onPlacementMouseDown, true);
+ if (this.placementSavedCursor !== null)
+ canvas.style.cursor = this.placementSavedCursor;
+ }
+ document.removeEventListener('keydown', this.onPlacementKeyDown, true);
+ this.placementCommentData = null;
+ this.placementSavedCursor = null;
+ }
+
+ private onPlacementMouseDown(e: MouseEvent): void {
+ if (e.button !== 0) return; // ignore right/middle clicks
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ this.finishCommentPlacement(e, e.currentTarget as HTMLCanvasElement);
+ }
+
+ private onPlacementKeyDown(e: KeyboardEvent): void {
+ if (e.key !== 'Escape') return;
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ this.exitCommentPlacement();
+ }
+
+ private finishCommentPlacement(e: MouseEvent, canvas: HTMLCanvasElement): void {
+ const commentData = this.placementCommentData;
+ // canvasToDocumentPoint expects coordinates relative to the canvas DOM
+ // element.
+ const rect = canvas.getBoundingClientRect();
+ const point = new cool.SimplePoint(0, 0);
+ point.cX = e.clientX - rect.left;
+ point.cY = e.clientY - rect.top;
+
+ const layout = app.activeDocument.activeLayout;
+ const docPoint = layout.canvasToDocumentPoint(point);
+ if (Number.isNaN(docPoint.x) || Number.isNaN(docPoint.y))
+ return;
+
+ // docPoint.x/.y are stacked-document twips. fileBasedView lays pages
+ // out vertically with a fixed _spaceBetweenParts gap. Compute the
+ // containing page and reject clicks that fell in a horizontal margin,
+ // in an inter-page gap, or past the last page - the user should stay
+ // in placement mode and try again on a real page.
+ const docLayer = app.map._docLayer;
+ const additionPerPart = docLayer._partHeightTwips + docLayer._spaceBetweenParts;
+ const partIndex = Math.floor(docPoint.y / additionPerPart);
+ const yInPart = docPoint.y - partIndex * additionPerPart;
+ if (partIndex < 0 || partIndex >= docLayer._parts
+ || docPoint.x < 0 || docPoint.x > docLayer._partWidthTwips
+ || yInPart > docLayer._partHeightTwips)
+ return;
+
+ // Switching the active part keeps save()'s setPart wrapper consistent
+ // and lets newAnnotation pick up the correct parthash; yAddition lets
+ // save() back the per-page offset out before sending PositionY.
+ commentData.yAddition = partIndex * additionPerPart;
+ this.map.setPart(partIndex, false);
+
+ commentData.position = [docPoint.x, docPoint.y];
+ this.exitCommentPlacement();
+ this.sectionProperties.docLayer.newAnnotation(commentData);
+ }
+
public click (annotation: any): void {
this.select(annotation);
app.map.fire('postMessage', {
@@ -798,6 +901,21 @@ export class CommentSection extends CanvasSectionObject {
public save (annotation: any): void {
var comment;
if (
annotation.sectionProperties.data.id === 'new') {
+ // PDF click-to-place: when the user picked a position before
+ // opening the editor, ship it as twips so core can place the
+ // annotation there instead of (0, 0). Mirrors the EditAnnotation
+ // pattern in CommentMarkerSubSection.sendAnnotationPositionChange.
+ let positionArgs: any = {};
+ if (annotation.sectionProperties.data.position) {
+ const pos = annotation.sectionProperties.data.position;
+ let py = pos[1];
+ if (app.file.fileBasedView)
+ py -= annotation.sectionProperties.data.yAddition || 0;
+ positionArgs = {
+ PositionX: { type: 'int32', value: pos[0] },
+ PositionY: { type: 'int32', value: py },
+ };
+ }
comment = {
Author: {
type: 'string',
@@ -813,7 +931,8 @@ export class CommentSection extends CanvasSectionObject {
{ Text: {
type: 'string',
value: annotation.sectionProperties.data.text
- } }
+ } },
+ ...positionArgs
};
var unoCommand = annotation.sectionProperties.data.threaded
? '.uno:InsertThreadedComment' : '.uno:InsertAnnotation';
diff --git a/browser/src/layer/tile/ImpressTileLayer.js b/browser/src/layer/tile/ImpressTileLayer.js
index b42f133f9987..11e2b6449738 100644
--- a/browser/src/layer/tile/ImpressTileLayer.js
+++ b/browser/src/layer/tile/ImpressTileLayer.js
@@ -125,8 +125,17 @@ window.L.ImpressTileLayer = window.L.CanvasTileLayer.extend({
},
newAnnotation: function (commentData) {
- commentData.anchorPos = [app.activeDocument.activeLayout.viewedRectangle.x1, app.activeDocument.activeLayout.viewedRectangle.y1];
- commentData.rectangle = [app.activeDocument.activeLayout.viewedRectangle.x1, app.activeDocument.activeLayout.viewedRectangle.y1, 566, 566];
+ // commentData.position (twips, top-left) lets the caller pin the new
+ // comment to a specific document point; used by the PDF click-to-place
+ // flow. Without it the marker lands at the current viewport top-left.
+ const anchorX = commentData.position
+ ? commentData.position[0]
+ : app.activeDocument.activeLayout.viewedRectangle.x1;
+ const anchorY = commentData.position
+ ? commentData.position[1]
+ : app.activeDocument.activeLayout.viewedRectangle.y1;
+ commentData.anchorPos = [anchorX, anchorY];
+ commentData.rectangle = [anchorX, anchorY, 566, 566];
commentData.parthash = app.impress.partList[this._selectedPart].hash;
diff --git a/cypress_test/integration_tests/desktop/draw/annotation_spec.js b/cypress_test/integration_tests/desktop/draw/annotation_spec.js
index 441be83f20ff..7d9cc54b2a45 100644
--- a/cypress_test/integration_tests/desktop/draw/annotation_spec.js
+++ b/cypress_test/integration_tests/desktop/draw/annotation_spec.js
@@ -189,4 +189,241 @@ describe(['tagdesktop'], 'PDF Threaded Comments', function() {
assertCommentShownAndAnchorVisible(win, 'Zoe');
});
});
+
+ // Insert Comment on a PDF defers comment creation until the user clicks
+ // on the page: the toolbar/menu action enters a placement mode (crosshair
+ // cursor, capture-phase mousedown listener) and the captured click is
+ // converted to document twips for both the on-screen anchor and the
+ // .uno:InsertAnnotation PositionX/Y args.
+ it('Insert Comment in PDF enters placement mode, pins the anchor to the click, and round-trips through core', { env: { 'pdf-view': true } }, function() {
+ const commentText = 'placement-mode-test ' + Date.now();
+ let initialCount = 0;
+
+ cy.getFrameWindow().then(function(win) {
+ const section = win.app.sectionContainer.getSectionWithName(
+
win.app.CSections.CommentList.name);
+ initialCount = section.sectionProperties.commentList.length;
+ win.app.map.insertComment();
+ });
+
+ cy.getFrameWindow().should(function(win) {
+ expect(win.document.getElementById('document-canvas').style.cursor,
+ 'placement mode must switch the canvas cursor to crosshair')
+ .to.equal('crosshair');
+
+ const section = win.app.sectionContainer.getSectionWithName(
+
win.app.CSections.CommentList.name);
+ expect(section.sectionProperties.commentList.length,
+ 'no comment should be created until the user picks a position')
+ .to.equal(initialCount);
+ });
+
+ let expectedX = 0, expectedY = 0;
+ cy.getFrameWindow().then(function(win) {
+ const canvas = win.document.getElementById('document-canvas');
+ const rect = canvas.getBoundingClientRect();
+
+ // Aim for ~25% across page 1 in document twips, then map back to
+ // canvas-relative CSS pixels.
+ const docLayer = win.app.map._docLayer;
+ const targetTwipsX = docLayer._partWidthTwips / 4;
+ const targetTwipsY = docLayer._partHeightTwips / 4;
+ const canvasClickX = (targetTwipsX * win.app.twipsToPixels) / win.app.dpiScale;
+ const canvasClickY = (targetTwipsY * win.app.twipsToPixels) / win.app.dpiScale;
+
+ // canvasToDocumentPoint mutates its input, so build a fresh
+ // SimplePoint here that matches finishCommentPlacement's local point.
+ const expectedPoint = new win.cool.SimplePoint(0, 0);
+ expectedPoint.cX = canvasClickX;
+ expectedPoint.cY = canvasClickY;
+ const docPoint = win.app.activeDocument.activeLayout.canvasToDocumentPoint(expectedPoint);
+ expectedX = docPoint.x;
+ expectedY = docPoint.y;
+
+ // Capture-phase mousedown listener inside startCommentPlacement
+ // consumes this; bubbles:true is needed so the capture path runs.
+ const ev = new win.MouseEvent('mousedown', {
+ clientX: rect.left + canvasClickX,
+ clientY: rect.top + canvasClickY,
+ button: 0,
+ bubbles: true,
+ });
+ canvas.dispatchEvent(ev);
+ });
+
+ // Local 'new' comment exists with the expected on-screen anchor.
+ cy.getFrameWindow().should(function(win) {
+ expect(win.document.getElementById('document-canvas').style.cursor,
+ 'cursor must be restored after placement finishes')
+ .to.not.equal('crosshair');
+
+ const section = win.app.sectionContainer.getSectionWithName(
+
win.app.CSections.CommentList.name);
+ const newComment = section.sectionProperties.commentList.find(
+ function(c) { return
c.sectionProperties.data.id === 'new'; });
+ expect(newComment, 'new comment was not created after the click').to.exist;
+
+ const anchorPos = newComment.sectionProperties.data.anchorPos;
+ expect(anchorPos[0],
+ 'on-screen anchor X must match canvasToDocumentPoint of the click').to.be.closeTo(expectedX, 1);
+ expect(anchorPos[1],
+ 'on-screen anchor Y must match canvasToDocumentPoint of the click').to.be.closeTo(expectedY, 1);
+ });
+
+ // Wait for the editor to render with a textarea, then settle so its
+ // id has flipped from 'new' to the final value.
+ cy.cGet('.cool-annotation').last({log: false})
+ .find('#annotation-modify-textarea-new').should('exist');
+ cy.getFrameWindow().then(function(win) { return helper.processToIdle(win); });
+ cy.cGet('.cool-annotation').last({log: false})
+ .find('.modify-annotation .cool-annotation-textarea')
+ .should('not.have.attr', 'disabled');
+ cy.cGet('.cool-annotation').last({log: false})
+ .find('.modify-annotation .cool-annotation-textarea')
+ .type(commentText);
+ cy.cGet('.cool-annotation').last({log: false})
+ .find('[value="Save"]').click();
+
+ // After core's acknowledgement: the commentList grew by one, the new
+ // comment carries a non-'new' id assigned by core, and its anchor
+ // survived the round-trip - the position core stored matches the
+ // position the placement-mode click captured.
+ cy.getFrameWindow().should(function(win) {
+ const section = win.app.sectionContainer.getSectionWithName(
+
win.app.CSections.CommentList.name);
+ expect(section.sectionProperties.commentList.length,
+ 'commentList must have one more entry after save')
+ .to.equal(initialCount + 1);
+
+ const acked = section.sectionProperties.commentList.find(function(c) {
+ return c.sectionProperties.data.text === commentText;
+ });
+ expect(acked, 'a comment with the typed text must be present').to.exist;
+ expect(
acked.sectionProperties.data.id,
+ 'core must assign a real (non-\'new\') id').to.not.equal('new');
+
+ // Round-trip goes twips -> mm/100 (core) -> twips, so allow a few
+ // twips of rounding slack rather than asserting exact equality.
+ const anchorPos = acked.sectionProperties.data.anchorPos;
+ expect(anchorPos[0],
+ 'round-tripped anchor X must match the captured click').to.be.closeTo(expectedX, 5);
+ expect(anchorPos[1],
+ 'round-tripped anchor Y must match the captured click').to.be.closeTo(expectedY, 5);
+ });
+ });
+
+ // Negative path through placement mode: enter placement, miss the page
+ // (no comment), then click on the page (anchor visible at the click
+ // point), then cancel the editor before saving (no comment ends up in
+ // the document at all).
+ it('Insert Comment placement mode: off-page miss, on-page anchor, cancel', { env: { 'pdf-view': true } }, function() {
+ let initialCount = 0;
+
+ cy.getFrameWindow().then(function(win) {
+ const section = win.app.sectionContainer.getSectionWithName(
+
win.app.CSections.CommentList.name);
+ initialCount = section.sectionProperties.commentList.length;
+ win.app.map.insertComment();
+ });
+
+ cy.getFrameWindow().should(function(win) {
+ expect(win.document.getElementById('document-canvas').style.cursor)
+ .to.equal('crosshair');
+ });
+
+ // Off-page click. Compute an X past the page's right edge from
+ // _partWidthTwips so it's guaranteed off-page regardless of zoom or
+ // scroll.
+ cy.getFrameWindow().then(function(win) {
+ const canvas = win.document.getElementById('document-canvas');
+ const rect = canvas.getBoundingClientRect();
+ const docLayer = win.app.map._docLayer;
+ const offPageCssX = (docLayer._partWidthTwips
+ * win.app.twipsToPixels) / win.app.dpiScale + 50;
+ const ev = new win.MouseEvent('mousedown', {
+ clientX: rect.left + offPageCssX,
+ clientY: rect.top + 200,
+ button: 0,
+ bubbles: true,
+ });
+ canvas.dispatchEvent(ev);
+ });
+
+ cy.getFrameWindow().then(function(win) { return helper.processToIdle(win); });
+ cy.getFrameWindow().should(function(win) {
+ expect(win.document.getElementById('document-canvas').style.cursor,
+ 'placement mode should remain active after an off-page click')
+ .to.equal('crosshair');
+
+ const section = win.app.sectionContainer.getSectionWithName(
+
win.app.CSections.CommentList.name);
+ expect(section.sectionProperties.commentList.length,
+ 'no comment should be added on an off-page click')
+ .to.equal(initialCount);
+ });
+
+ // On-page click - placement mode exits, a 'new' comment gets the
+ // expected anchor and the editor opens.
+ let expectedX = 0, expectedY = 0;
+ cy.getFrameWindow().then(function(win) {
+ const canvas = win.document.getElementById('document-canvas');
+ const rect = canvas.getBoundingClientRect();
+
+ // Same target-twips-back-to-CSS-pixels strategy as the happy-path
+ // test - aims for ~25% across page 1 regardless of zoom.
+ const docLayer = win.app.map._docLayer;
+ const targetTwipsX = docLayer._partWidthTwips / 4;
+ const targetTwipsY = docLayer._partHeightTwips / 4;
+ const canvasClickX = (targetTwipsX * win.app.twipsToPixels) / win.app.dpiScale;
+ const canvasClickY = (targetTwipsY * win.app.twipsToPixels) / win.app.dpiScale;
+
+ const expectedPoint = new win.cool.SimplePoint(0, 0);
+ expectedPoint.cX = canvasClickX;
+ expectedPoint.cY = canvasClickY;
+ const docPoint = win.app.activeDocument.activeLayout.canvasToDocumentPoint(expectedPoint);
+ expectedX = docPoint.x;
+ expectedY = docPoint.y;
+
+ const ev = new win.MouseEvent('mousedown', {
+ clientX: rect.left + canvasClickX,
+ clientY: rect.top + canvasClickY,
+ button: 0,
+ bubbles: true,
+ });
+ canvas.dispatchEvent(ev);
+ });
+
+ cy.getFrameWindow().should(function(win) {
+ expect(win.document.getElementById('document-canvas').style.cursor,
+ 'cursor must be restored after the on-page click')
+ .to.not.equal('crosshair');
+
+ const section = win.app.sectionContainer.getSectionWithName(
+
win.app.CSections.CommentList.name);
+ const newComment = section.sectionProperties.commentList.find(
+ function(c) { return
c.sectionProperties.data.id === 'new'; });
+ expect(newComment, 'new comment must be created at the click').to.exist;
+ const anchorPos = newComment.sectionProperties.data.anchorPos;
+ expect(anchorPos[0],
+ 'temporary anchor X must match the click').to.be.closeTo(expectedX, 1);
+ expect(anchorPos[1],
+ 'temporary anchor Y must match the click').to.be.closeTo(expectedY, 1);
+ });
+
+ // Cancel the editor before typing/saving.
+ cy.cGet('.cool-annotation').last({log: false})
+ .find('#annotation-modify-textarea-new').should('exist');
+ cy.cGet('.cool-annotation').last({log: false})
+ .find('#annotation-cancel-new').click();
+
+ // Settle and verify the document carries no extra comment.
+ cy.getFrameWindow().then(function(win) { return helper.processToIdle(win); });
+ cy.getFrameWindow().should(function(win) {
+ const section = win.app.sectionContainer.getSectionWithName(
+
win.app.CSections.CommentList.name);
+ expect(section.sectionProperties.commentList.length,
+ 'no comment should remain after cancelling the editor')
+ .to.equal(initialCount);