Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions app/assets/javascripts/Annotations/html_annotations.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,22 @@ function check_annotation_overlap(range) {
);
}

function get_html_annotation_range() {
function get_html_annotation_range(warn = true) {
const iframe = document.getElementById("html-content");
const target = iframe.contentDocument;
const selection = target.getSelection();
if (selection.rangeCount >= 1) {
const range = selection.getRangeAt(0);
if (check_annotation_overlap(range)) {
alert(I18n.t("results.annotation.no_overlap"));
return {};
return null;
}
if (range.startOffset !== range.endOffset || range.startContainer !== range.endContainer) {
return range;
}
}
alert(I18n.t("results.annotation.select_some_text"));
return {};
if (warn) {
alert(I18n.t("results.annotation.select_some_text"));
}
return null;
}
35 changes: 35 additions & 0 deletions app/assets/javascripts/Annotations/image_annotation_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,41 @@ class ImageAnnotationManager extends AnnotationManager {
return box;
}

/**
* Returns a fallback 40×40 px selection centred at the last right-click position,
* for use when no area is currently selected.
* @returns {{x1, y1, x2, y2}|false}
*/
getFallbackSelection() {
const img = this.image_preview; // the <img> DOM element
if (!img) return false;

const rect = img.getBoundingClientRect();
const displayWidth = rect.width;
const displayHeight = rect.height;
if (displayWidth === 0 || displayHeight === 0) return false;

// Convert page click position to position within the displayed image.
const e = this.last_click_event;
const clickX = e ? e.clientX - rect.left : displayWidth / 2;
const clickY = e ? e.clientY - rect.top : displayHeight / 2;

// Scale from display pixels to image natural pixels.
const scaleX = img.naturalWidth / displayWidth;
const scaleY = img.naturalHeight / displayHeight;
const imgX = Math.round(clickX * scaleX);
const imgY = Math.round(clickY * scaleY);

// 40x40 box centred at click, clamped to image bounds.
const half = 20;
return {
x1: Math.max(0, imgX - half),
y1: Math.max(0, imgY - half),
x2: Math.min(img.naturalWidth, imgX + half),
y2: Math.min(img.naturalHeight, imgY + half),
};
}

get_selection_box_coordinates() {
let img = this.image_preview;
let zoomHeight = img.height;
Expand Down
64 changes: 64 additions & 0 deletions app/assets/javascripts/Annotations/pdf_annotation_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,70 @@
$page.append($control);
};

/**
* Returns a fallback 40×40 selection centred at the last right-click position,
* for use when no area is currently selected.
* Applies the same inverse-rotation correction as getSelection() so that
* renderAnnotation()'s forward rotation produces the correct screen position.
* @returns {{x1, y1, x2, y2, page}|false}
*/
getFallbackSelection() {
// Find which page element was right-clicked.
const e = this.last_click_event;
let pageEl = null;
let pageNumber = 1;

if (e) {
// Walk up from the event target to find the page container.
let el = document.elementFromPoint(e.clientX, e.clientY);
while (el && el !== document.body) {
if (el.dataset && el.dataset.pageNumber) {
pageEl = el;
pageNumber = parseInt(el.dataset.pageNumber, 10);
break;
}
el = el.parentElement;
}
}

if (!pageEl) {
// Fall back to first visible page.
const $firstPage = $(".page[data-page-number]").first();
if ($firstPage.length) {
pageEl = $firstPage[0];
pageNumber = $firstPage.data("page-number");
}
}

if (!pageEl) return false;

const rect = pageEl.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;

// Click position as percentage of page dimensions, scaled by COORDINATE_MULTIPLIER.
const clickX = e ? e.clientX - rect.left : rect.width / 2;
const clickY = e ? e.clientY - rect.top : rect.height / 2;
const cx = Math.round((clickX / rect.width) * COORDINATE_MULTIPLIER);
const cy = Math.round((clickY / rect.height) * COORDINATE_MULTIPLIER);

// 40px in COORDINATE_MULTIPLIER units relative to page size.
const halfX = Math.round((20 / rect.width) * COORDINATE_MULTIPLIER);
const halfY = Math.round((20 / rect.height) * COORDINATE_MULTIPLIER);

// 40x40 box centred at click, clamped to page bounds.
const box = {
x1: Math.max(0, cx - halfX),
y1: Math.max(0, cy - halfY),
x2: Math.min(COORDINATE_MULTIPLIER, cx + halfX),
y2: Math.min(COORDINATE_MULTIPLIER, cy + halfY),
};

// Apply the same inverse-rotation correction that getSelection() applies,
// so renderAnnotation()'s forward rotation produces the correct screen position.
const rotated = getRotatedCoords(box, 360 - this.angle);
return {...rotated, page: pageNumber};
}

/**
* The following two functions are used to keep track of the orientation of
* the PDF so we know how to render the annotations.
Expand Down
21 changes: 21 additions & 0 deletions app/assets/javascripts/Annotations/text_annotation_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,27 @@ class TextAnnotationManager extends AnnotationManager {
};
}

/**
* Returns a fallback selection (first character of the first non-empty line)
* for use when no text is currently selected.
* @returns {{line_start, line_end, column_start, column_end}|false}
*/
getFallbackSelection() {
// source_lines[0] is a null dummy; real lines start at index 1.
for (let i = 1; i < this.source_lines.length; i++) {
const lineContent = this.source_lines[i] ? this.source_lines[i].line_node.textContent : "";
if (lineContent.trim().length > 0) {
return {
line_start: i,
line_end: i,
column_start: 0,
column_end: 1,
};
}
}
return false; // empty file or all blank lines
}

// Given some node, traverses upwards until it finds the span element that represents a line of code.
// This is useful for figuring out what text is currently selected, using window.getSelection().anchorNode / focusNode
getRootFromSelection(node) {
Expand Down
25 changes: 16 additions & 9 deletions app/javascript/Components/Result/context_menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,22 @@ export var annotation_context_menu = {
menu_items.download,
],
beforeOpen: function (event, ui) {
// Enable annotation menu items only if a selection has been made
var selection_exists = !!window.annotation_manager.getSelection(false);
$(document).contextmenu("enableEntry", "check_mark_annotation", selection_exists);
$(document).contextmenu("enableEntry", "thumbs_up_annotation", selection_exists);
$(document).contextmenu("enableEntry", "heart_annotation", selection_exists);
$(document).contextmenu("enableEntry", "smile_annotation", selection_exists);
$(document).contextmenu("enableEntry", "new_annotation", selection_exists);
$(document).contextmenu("enableEntry", "common_annotations", selection_exists);
$(document).contextmenu("enableEntry", "copy", selection_exists);
// Store right-click event so annotation managers can use the click position for fallback
// selections. Note: on touch/long-press (taphold: true above), clientX/clientY may be
// zero on the jQuery synthetic event — this is a pre-existing quirk of the taphold path.
if (window.annotation_manager) {
window.annotation_manager.last_click_event = event;
}
// Annotation creation items are always enabled; fallback selection synthesized on use.
$(document).contextmenu("enableEntry", "check_mark_annotation", true);
$(document).contextmenu("enableEntry", "thumbs_up_annotation", true);
$(document).contextmenu("enableEntry", "heart_annotation", true);
$(document).contextmenu("enableEntry", "smile_annotation", true);
$(document).contextmenu("enableEntry", "new_annotation", true);
$(document).contextmenu("enableEntry", "common_annotations", true);
// copy requires an actual browser text selection, not an annotation region.
var text_selected = !!(window.getSelection && window.getSelection().type === "Range");
$(document).contextmenu("enableEntry", "copy", text_selected);

var has_common_annot =
$(document).contextmenu("getMenu").find(".has_common_annotations").length > 0;
Expand Down
74 changes: 66 additions & 8 deletions app/javascript/Components/Result/result.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,18 +337,31 @@ class Result extends React.Component {
extend_with_selection_data = annotation_data => {
let box;
if (annotation_type === ANNOTATION_TYPES.HTML) {
const range = get_html_annotation_range();
box = {
start_node: pathToNode(range.startContainer),
start_offset: range.startOffset,
end_node: pathToNode(range.endContainer),
end_offset: range.endOffset,
};
const range = get_html_annotation_range(false); // suppress alert
if (range && range.startContainer) {
box = {
start_node: pathToNode(range.startContainer),
start_offset: range.startOffset,
end_node: pathToNode(range.endContainer),
end_offset: range.endOffset,
};
} else {
box = synthesize_html_fallback_selection();
}
} else {
box = window.annotation_manager.getSelection();
// annotation_manager is null only for HTML files, which are handled by the branch
// above. This guard is unreachable in production but kept as a defensive safety net.
if (!window.annotation_manager) return;
box = window.annotation_manager.getSelection(false);
if (!box) {
box = window.annotation_manager.getFallbackSelection();
}
}
if (box) {
return Object.assign(annotation_data, box);
} else {
alert(I18n.t("results.annotation.cannot_annotate_empty"));
return undefined;
}
};

Expand Down Expand Up @@ -1091,3 +1104,48 @@ export function makeResult(elem, props) {
root.render(<Result {...props} ref={component} />);
return component;
}

/**
* Synthesize a fallback HTML annotation selection for when no text is currently selected
* in the HTML iframe. Finds the first text node in the iframe body and creates a
* single-character range at offset 0.
*
* Must live in result.jsx (not html_annotations.js) because it uses pathToNode,
* which is an ES module export and is not available in the legacy IIFE scripts.
*
* @returns {{start_node, start_offset, end_node, end_offset}|null}
*/
export function synthesize_html_fallback_selection() {
const iframe = document.getElementById("html-content");
if (!iframe) return null;
const target = iframe.contentDocument;
if (!target || !target.body) return null;

function findFirstTextNode(node) {
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
return node;
}
for (const child of node.childNodes) {
const found = findFirstTextNode(child);
if (found) return found;
}
return null;
}

const textNode = findFirstTextNode(target.body);
if (!textNode) return null;

const range = target.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, 1);

if (typeof check_annotation_overlap === "function" && check_annotation_overlap(range))
return null;

return {
start_node: pathToNode(textNode),
start_offset: 0,
end_node: pathToNode(textNode),
end_offset: 1,
};
}
Loading