diff --git a/app/assets/javascripts/Annotations/html_annotations.js b/app/assets/javascripts/Annotations/html_annotations.js index 836eed637d..73c3354e0c 100644 --- a/app/assets/javascripts/Annotations/html_annotations.js +++ b/app/assets/javascripts/Annotations/html_annotations.js @@ -31,7 +31,7 @@ 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(); @@ -39,12 +39,14 @@ function get_html_annotation_range() { 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; } diff --git a/app/assets/javascripts/Annotations/image_annotation_manager.js b/app/assets/javascripts/Annotations/image_annotation_manager.js index 4f4ec91f95..255429abda 100644 --- a/app/assets/javascripts/Annotations/image_annotation_manager.js +++ b/app/assets/javascripts/Annotations/image_annotation_manager.js @@ -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 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; diff --git a/app/assets/javascripts/Annotations/pdf_annotation_manager.js b/app/assets/javascripts/Annotations/pdf_annotation_manager.js index ee01a5f513..475cbe161e 100644 --- a/app/assets/javascripts/Annotations/pdf_annotation_manager.js +++ b/app/assets/javascripts/Annotations/pdf_annotation_manager.js @@ -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. diff --git a/app/assets/javascripts/Annotations/text_annotation_manager.js b/app/assets/javascripts/Annotations/text_annotation_manager.js index 2a5dc6101d..7964e1981e 100644 --- a/app/assets/javascripts/Annotations/text_annotation_manager.js +++ b/app/assets/javascripts/Annotations/text_annotation_manager.js @@ -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) { diff --git a/app/javascript/Components/Result/context_menu.js b/app/javascript/Components/Result/context_menu.js index 1563f2edaf..1740925d52 100644 --- a/app/javascript/Components/Result/context_menu.js +++ b/app/javascript/Components/Result/context_menu.js @@ -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; diff --git a/app/javascript/Components/Result/result.jsx b/app/javascript/Components/Result/result.jsx index 16d60603e5..7260e2429c 100644 --- a/app/javascript/Components/Result/result.jsx +++ b/app/javascript/Components/Result/result.jsx @@ -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; } }; @@ -1091,3 +1104,48 @@ export function makeResult(elem, props) { root.render(); 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, + }; +} diff --git a/app/javascript/Components/__tests__/annotation_managers.test.js b/app/javascript/Components/__tests__/annotation_managers.test.js new file mode 100644 index 0000000000..56d5cadd92 --- /dev/null +++ b/app/javascript/Components/__tests__/annotation_managers.test.js @@ -0,0 +1,556 @@ +/** + * Tests for getFallbackSelection() on the annotation manager classes, + * for get_html_annotation_range() with the new warn parameter, + * and for synthesize_html_fallback_selection() in result.jsx. + * + * annotation_manager.js, text_annotation_manager.js, and image_annotation_manager.js + * are plain class declarations (no IIFE, no window exports). When require()'d by Jest + * they are scoped to that module and don't become globals. We eval() them into the + * global scope so that subclasses can find their base classes. + * + * pdf_annotation_manager.js IS an IIFE that does window.PdfAnnotationManager = ..., + * so it is require()'d normally after the base class is in global scope. + */ + +// result.jsx imports @rails/ujs which throws when jquery_ujs is already present in the +// Jest environment. Mock it out so we can import synthesize_html_fallback_selection. +jest.mock("@rails/ujs", () => {}); + +const fs = require("fs"); +const path = require("path"); + +/** + * Load a plain script and assign its top-level class declaration(s) to global. + * `class` declarations are block-scoped even in eval/Function, so we use a + * Function wrapper that receives `global` as a parameter, executes the script + * source, then explicitly assigns the named class to global. + */ +function loadScript(relPath, exportNames) { + const src = fs.readFileSync(path.resolve(__dirname, relPath), "utf8"); + const assignments = exportNames + .map(name => `if(typeof ${name}!=='undefined') _g.${name}=${name};`) + .join("\n"); + // eslint-disable-next-line no-new-func + new Function("_g", src + "\n" + assignments)(global); +} + +// Stub dependency classes before loading the manager scripts. +global.AnnotationTextDisplayer = class { + hide() {} + displayCollection() {} + setDisplayNodeParent() {} +}; + +global.AnnotationTextManager = class { + findOrCreateAnnotationText() { + return {}; + } +}; + +global.SourceCodeLine = class { + constructor(node) { + this.line_node = node; + } + glow() {} + unglow() {} +}; + +// Load the base class and subclasses into global scope. +// From __tests__/ go up 3 levels to reach app/, then into assets/javascripts/. +loadScript("../../../assets/javascripts/Annotations/annotation_manager.js", ["AnnotationManager"]); +loadScript("../../../assets/javascripts/Annotations/text_annotation_manager.js", [ + "TextAnnotationManager", +]); +loadScript("../../../assets/javascripts/Annotations/image_annotation_manager.js", [ + "ImageAnnotationManager", +]); + +// PdfAnnotationManager is an IIFE that exports via window.PdfAnnotationManager. +// It depends on AnnotationManager already being in global scope, which it now is. +require("../../../assets/javascripts/Annotations/pdf_annotation_manager.js"); + +// html_annotations.js defines plain functions; load and export them to global. +loadScript("../../../assets/javascripts/Annotations/html_annotations.js", [ + "get_html_annotation_range", + "check_annotation_overlap", + "descendant_of_annotation", + "ancestor_of_annotation", +]); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** Build a minimal TextAnnotationManager from an array of line content strings. */ +function makeTextManager(lines) { + const nodes = lines.map(text => { + const node = document.createElement("span"); + node.textContent = text; + return node; + }); + return new global.TextAnnotationManager(nodes); +} + +/** Build a minimal ImageAnnotationManager with a mock image element. */ +function makeImageManager({ + naturalWidth = 200, + naturalHeight = 100, + displayWidth = 400, + displayHeight = 200, + rectLeft = 0, + rectTop = 0, +} = {}) { + const img = document.createElement("img"); + Object.defineProperty(img, "naturalWidth", {value: naturalWidth, configurable: true}); + Object.defineProperty(img, "naturalHeight", {value: naturalHeight, configurable: true}); + img.getBoundingClientRect = () => ({ + left: rectLeft, + top: rectTop, + width: displayWidth, + height: displayHeight, + }); + + const selBox = document.createElement("div"); + + const origGetById = document.getElementById.bind(document); + jest.spyOn(document, "getElementById").mockImplementation(id => { + if (id === "image_preview") return img; + if (id === "sel_box") return selBox; + return origGetById(id); + }); + + const mgr = new global.ImageAnnotationManager(false); + document.getElementById.mockRestore(); + return {mgr, img}; +} + +// ─── TextAnnotationManager.getFallbackSelection ────────────────────────────── + +describe("TextAnnotationManager.getFallbackSelection()", () => { + it("returns line 1 selection when line 1 is non-empty", () => { + const mgr = makeTextManager(["hello world", "second line"]); + expect(mgr.getFallbackSelection()).toEqual({ + line_start: 1, + line_end: 1, + column_start: 0, + column_end: 1, + }); + }); + + it("skips a blank line 1 and returns line 2 when that is first non-empty", () => { + const mgr = makeTextManager(["", "second line"]); + expect(mgr.getFallbackSelection()).toEqual({ + line_start: 2, + line_end: 2, + column_start: 0, + column_end: 1, + }); + }); + + it("skips whitespace-only lines and returns first line with non-whitespace content", () => { + // Line 1 = "" (empty), line 2 = " " (spaces only, trimmed length = 0), line 3 = "third". + // The fallback uses lineContent.trim().length > 0, so line 2 is skipped and line 3 is returned. + const mgr = makeTextManager(["", " ", "third"]); + expect(mgr.getFallbackSelection()).toEqual({ + line_start: 3, + line_end: 3, + column_start: 0, + column_end: 1, + }); + }); + + it("returns false for an empty file (no lines)", () => { + const mgr = makeTextManager([]); + expect(mgr.getFallbackSelection()).toBe(false); + }); + + it("returns false when all lines are empty strings", () => { + const mgr = makeTextManager(["", "", ""]); + expect(mgr.getFallbackSelection()).toBe(false); + }); + + it("returns false when all lines are whitespace-only", () => { + const mgr = makeTextManager([" ", "\t", " "]); + expect(mgr.getFallbackSelection()).toBe(false); + }); +}); + +// ─── ImageAnnotationManager.getFallbackSelection ───────────────────────────── + +describe("ImageAnnotationManager.getFallbackSelection()", () => { + it("returns a 40×40 box in image-pixel space centred at the click position", () => { + const {mgr} = makeImageManager({ + naturalWidth: 200, + naturalHeight: 100, + displayWidth: 400, + displayHeight: 200, + rectLeft: 0, + rectTop: 0, + }); + + // Click at display pixel (100, 50); rect origin is (0,0). + // scaleX = 200/400 = 0.5 → imgX = round(100 * 0.5) = 50 + // scaleY = 100/200 = 0.5 → imgY = round(50 * 0.5) = 25 + // x1 = max(0, 50-20)=30, x2 = min(200, 50+20)=70 + // y1 = max(0, 25-20)=5, y2 = min(100, 25+20)=45 + mgr.last_click_event = {clientX: 100, clientY: 50}; + expect(mgr.getFallbackSelection()).toEqual({x1: 30, y1: 5, x2: 70, y2: 45}); + }); + + it("falls back to image centre when last_click_event is null", () => { + const {mgr} = makeImageManager({ + naturalWidth: 200, + naturalHeight: 100, + displayWidth: 400, + displayHeight: 200, + }); + mgr.last_click_event = null; + + // clickX = displayWidth/2 = 200 → imgX = round(200 * 0.5) = 100 + // clickY = displayHeight/2 = 100 → imgY = round(100 * 0.5) = 50 + // x1=80, x2=120, y1=30, y2=70 + expect(mgr.getFallbackSelection()).toEqual({x1: 80, y1: 30, x2: 120, y2: 70}); + }); + + it("clamps x1 to 0 when click is within 20 image-px of the left edge", () => { + const {mgr} = makeImageManager({ + naturalWidth: 200, + naturalHeight: 100, + displayWidth: 400, + displayHeight: 200, + }); + // Display click at (10, 50) → imgX = round(10 * 0.5) = 5; x1 = max(0, 5-20) = 0 + mgr.last_click_event = {clientX: 10, clientY: 50}; + const result = mgr.getFallbackSelection(); + expect(result.x1).toBe(0); + expect(result.x2).toBe(25); + }); + + it("clamps x2 to naturalWidth when click is within 20 image-px of the right edge", () => { + const {mgr} = makeImageManager({ + naturalWidth: 200, + naturalHeight: 100, + displayWidth: 400, + displayHeight: 200, + }); + // Display click at (390, 50) → imgX = round(390 * 0.5) = 195; x2 = min(200, 215) = 200 + mgr.last_click_event = {clientX: 390, clientY: 50}; + const result = mgr.getFallbackSelection(); + expect(result.x1).toBe(175); + expect(result.x2).toBe(200); + }); + + it("returns false when the displayed image has zero dimensions", () => { + const img = document.createElement("img"); + Object.defineProperty(img, "naturalWidth", {value: 200, configurable: true}); + Object.defineProperty(img, "naturalHeight", {value: 100, configurable: true}); + img.getBoundingClientRect = () => ({left: 0, top: 0, width: 0, height: 0}); + + const selBox = document.createElement("div"); + const origGetById = document.getElementById.bind(document); + jest.spyOn(document, "getElementById").mockImplementation(id => { + if (id === "image_preview") return img; + if (id === "sel_box") return selBox; + return origGetById(id); + }); + const mgr = new global.ImageAnnotationManager(false); + document.getElementById.mockRestore(); + + expect(mgr.getFallbackSelection()).toBe(false); + }); +}); + +// ─── PdfAnnotationManager.getFallbackSelection ─────────────────────────────── + +describe("PdfAnnotationManager.getFallbackSelection()", () => { + const MULT = 100000; + let pageEl; + + beforeEach(() => { + pageEl = document.createElement("div"); + pageEl.dataset.pageNumber = "1"; + document.body.appendChild(pageEl); + // 500 wide × 1000 tall page, top-left at (0,0). + pageEl.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 500, + height: 1000, + }); + document.elementFromPoint = jest.fn(() => pageEl); + + // Stub jQuery to return the page element for .page[data-page-number] queries. + const origJQ = global.$; + global.$ = jest.fn(selector => { + if (typeof selector === "string" && selector.includes(".page")) { + return { + length: 1, + first: () => ({length: 1, 0: pageEl, data: () => 1}), + }; + } + return origJQ(selector); + }); + }); + + afterEach(() => { + if (pageEl.parentNode) pageEl.parentNode.removeChild(pageEl); + global.$ = window.jQuery; + delete document.elementFromPoint; + }); + + it("returns a box centred at the click position at 0° rotation", () => { + const mgr = new window.PdfAnnotationManager(false); + mgr.last_click_event = {clientX: 250, clientY: 500}; + + const result = mgr.getFallbackSelection(); + // cx = round(250/500 * 100000) = 50000 + // cy = round(500/1000 * 100000) = 50000 + // halfX = round(20/500 * 100000) = 4000 + // halfY = round(20/1000 * 100000) = 2000 + // At 0° no rotation applied. + expect(result).toEqual({ + page: 1, + x1: 46000, + x2: 54000, + y1: 48000, + y2: 52000, + }); + }); + + it("applies 270° rotation correction when angle is 90°", () => { + const mgr = new window.PdfAnnotationManager(false); + mgr.angle = 90; + mgr.last_click_event = {clientX: 250, clientY: 500}; + + const result = mgr.getFallbackSelection(); + // Pre-rotation box: x1=46000, y1=48000, x2=54000, y2=52000 + // Inverse rotation = 360-90 = 270. + // getRotatedCoords(box, 270): + // newX1 = y1 = 48000 + // newX2 = y2 = 52000 + // newY1 = MULT - x2 = 100000 - 54000 = 46000 + // newY2 = MULT - x1 = 100000 - 46000 = 54000 + expect(result).toEqual({ + page: 1, + x1: 48000, + x2: 52000, + y1: 46000, + y2: 54000, + }); + }); + + it("returns false when no page element is found anywhere", () => { + document.elementFromPoint = jest.fn(() => null); + global.$ = jest.fn(() => ({length: 0, first: () => ({length: 0})})); + + const mgr = new window.PdfAnnotationManager(false); + mgr.last_click_event = {clientX: 250, clientY: 500}; + + expect(mgr.getFallbackSelection()).toBe(false); + }); +}); + +// ─── get_html_annotation_range() ───────────────────────────────────────────── + +describe("get_html_annotation_range()", () => { + let iframeDoc; + + beforeEach(() => { + iframeDoc = {getSelection: () => ({rangeCount: 0})}; + const iframe = document.createElement("iframe"); + Object.defineProperty(iframe, "contentDocument", { + value: iframeDoc, + configurable: true, + }); + const origGetById = document.getElementById.bind(document); + jest + .spyOn(document, "getElementById") + .mockImplementation(id => (id === "html-content" ? iframe : origGetById(id))); + }); + + afterEach(() => { + document.getElementById.mockRestore(); + }); + + it("returns null without alerting when warn=false and no selection exists", () => { + const alertSpy = jest.spyOn(window, "alert").mockImplementation(() => {}); + expect(global.get_html_annotation_range(false)).toBeNull(); + expect(alertSpy).not.toHaveBeenCalled(); + alertSpy.mockRestore(); + }); + + it("alerts and returns null when warn=true (default) and no selection exists", () => { + const alertSpy = jest.spyOn(window, "alert").mockImplementation(() => {}); + expect(global.get_html_annotation_range()).toBeNull(); + expect(alertSpy).toHaveBeenCalledTimes(1); + alertSpy.mockRestore(); + }); + + it("returns the range when a valid non-collapsed selection exists", () => { + // Attach the text node to document.body so that descendant_of_annotation's + // parentNode walk terminates at document (Node.DOCUMENT_NODE) rather than null. + const container = document.createElement("span"); + document.body.appendChild(container); + const textNode = document.createTextNode("hello"); + container.appendChild(textNode); + + const mockRange = { + startContainer: textNode, + endContainer: textNode, + startOffset: 0, + endOffset: 3, + cloneContents: () => ({children: []}), + }; + iframeDoc.getSelection = () => ({rangeCount: 1, getRangeAt: () => mockRange}); + + expect(global.get_html_annotation_range(false)).toBe(mockRange); + + document.body.removeChild(container); + }); +}); + +// ─── context_menu beforeOpen ────────────────────────────────────────────────── + +describe("context_menu beforeOpen handler", () => { + let capturedOptions; + let enabledEntries; + let origAnnotationManager; + + beforeEach(() => { + origAnnotationManager = window.annotation_manager; + + // Spy on $.fn.contextmenu to capture the options object passed during setup(). + jest.spyOn($.fn, "contextmenu").mockImplementation(function (optsOrCmd) { + if (typeof optsOrCmd === "object") { + capturedOptions = optsOrCmd; + } else if (optsOrCmd === "enableEntry") { + // Track which entries are enabled/disabled: args are (cmd, entry, enabled). + const [, entry, enabled] = arguments; + enabledEntries[entry] = enabled; + } else if (optsOrCmd === "showEntry") { + // ignore + } else if (optsOrCmd === "getMenu") { + return {find: () => ({length: 0})}; + } + return this; + }); + + capturedOptions = null; + enabledEntries = {}; + + // Set up globals that context_menu.js reads. + global.ANNOTATION_TYPES = {CODE: 0, IMAGE: 1, PDF: 2, HTML: 3}; + global.annotation_type = global.ANNOTATION_TYPES.CODE; + global.resultComponent = {current: {addQuickAnnotation: jest.fn(), newAnnotation: jest.fn()}}; + }); + + afterEach(() => { + window.annotation_manager = origAnnotationManager; + $.fn.contextmenu.mockRestore(); + delete global.ANNOTATION_TYPES; + delete global.annotation_type; + delete global.resultComponent; + }); + + it("stores last_click_event on annotation_manager and enables all annotation creation items", async () => { + const {annotation_context_menu} = await import("../Result/context_menu.js"); + annotation_context_menu.setup(); + + expect(capturedOptions).not.toBeNull(); + + // Set up a mock annotation_manager. + const mockManager = {last_click_event: null}; + window.annotation_manager = mockManager; + + const fakeEvent = {clientX: 100, clientY: 200, type: "contextmenu"}; + const fakeUi = {target: document.createElement("div")}; + + capturedOptions.beforeOpen(fakeEvent, fakeUi); + + // The event should have been stored on the annotation_manager. + expect(mockManager.last_click_event).toBe(fakeEvent); + + // All annotation creation items should be enabled unconditionally. + expect(enabledEntries["check_mark_annotation"]).toBe(true); + expect(enabledEntries["thumbs_up_annotation"]).toBe(true); + expect(enabledEntries["heart_annotation"]).toBe(true); + expect(enabledEntries["smile_annotation"]).toBe(true); + expect(enabledEntries["new_annotation"]).toBe(true); + expect(enabledEntries["common_annotations"]).toBe(true); + }); + + it("does not throw when annotation_manager is null", async () => { + const {annotation_context_menu} = await import("../Result/context_menu.js"); + annotation_context_menu.setup(); + + window.annotation_manager = null; + + const fakeEvent = {clientX: 0, clientY: 0}; + const fakeUi = {target: document.createElement("div")}; + + // Should not throw even when annotation_manager is null. + expect(() => capturedOptions.beforeOpen(fakeEvent, fakeUi)).not.toThrow(); + }); +}); + +// ─── synthesize_html_fallback_selection() ──────────────────────────────────── + +describe("synthesize_html_fallback_selection()", () => { + let synthesize_html_fallback_selection; + + beforeEach(() => { + ({synthesize_html_fallback_selection} = require("../Result/result.jsx")); + }); + + afterEach(() => { + jest.resetModules(); + document.getElementById.mockRestore && document.getElementById.mockRestore(); + }); + + it("returns null when no iframe element exists", () => { + jest.spyOn(document, "getElementById").mockReturnValue(null); + expect(synthesize_html_fallback_selection()).toBeNull(); + }); + + it("returns null when iframe has no contentDocument", () => { + jest + .spyOn(document, "getElementById") + .mockImplementation(id => (id === "html-content" ? {contentDocument: null} : null)); + expect(synthesize_html_fallback_selection()).toBeNull(); + }); + + it("returns null when iframe body has no text content", () => { + const emptyBody = document.createElement("div"); + const fakeDoc = {body: emptyBody, createRange: () => document.createRange()}; + jest + .spyOn(document, "getElementById") + .mockImplementation(id => (id === "html-content" ? {contentDocument: fakeDoc} : null)); + expect(synthesize_html_fallback_selection()).toBeNull(); + }); + + it("returns start_node/end_node/offsets for body with text content", () => { + const textNode = document.createTextNode("Hello world"); + const bodyEl = document.createElement("div"); + bodyEl.appendChild(textNode); + + const fakeRange = { + setStart: jest.fn(), + setEnd: jest.fn(), + startContainer: textNode, + endContainer: textNode, + startOffset: 0, + endOffset: 1, + cloneContents: () => ({children: []}), + }; + const fakeDoc = {body: bodyEl, createRange: () => fakeRange}; + jest + .spyOn(document, "getElementById") + .mockImplementation(id => (id === "html-content" ? {contentDocument: fakeDoc} : null)); + global.check_annotation_overlap = jest.fn(() => false); + + const result = synthesize_html_fallback_selection(); + expect(result).not.toBeNull(); + expect(result.start_offset).toBe(0); + expect(result.end_offset).toBe(1); + expect(result.start_node).toBeDefined(); + expect(result.end_node).toBeDefined(); + }); +}); diff --git a/config/locales/views/results/en.yml b/config/locales/views/results/en.yml index 8577a6dce7..bf8a1f09ff 100644 --- a/config/locales/views/results/en.yml +++ b/config/locales/views/results/en.yml @@ -3,6 +3,7 @@ en: results: annotation: across_all_submission_files: Click on the filename to jump to the annotation. + cannot_annotate_empty: 'Cannot create annotation: the file has no content to annotate.' common: Common Annotations feedback_generated_header: 'Feedback generated from automated test run on %{time}:' include_in_download: Include Annotations