From 8f26d3d213b6de094f340a9f03dddb91a795931f Mon Sep 17 00:00:00 2001 From: jamesmisson Date: Wed, 29 Oct 2025 15:03:50 +0000 Subject: [PATCH 1/9] add anno manifests to examples --- src/iiif-collection.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/iiif-collection.json b/src/iiif-collection.json index 0b89926e5..bd759abfb 100644 --- a/src/iiif-collection.json +++ b/src/iiif-collection.json @@ -1179,6 +1179,30 @@ "@type": "sc:Manifest", "label": "Swedish National Archives", "visible": true + }, + { + "@id": "https://miiify-a58u.onrender.com/manifest/0001", + "@type": "sc:Manifest", + "label": "BL Annotations Test", + "visible": true + }, + { + "@id": "https://iiif.io/api/cookbook/recipe/0266-full-canvas-annotation/manifest.json", + "@type": "sc:Manifest", + "label": "IIIF Cookbook: Simplest Annotation (embedded)", + "visible": true + }, + { + "@id": "https://iiif.io/api/cookbook/recipe/0269-embedded-or-referenced-annotations/manifest.json", + "@type": "sc:Manifest", + "label": "IIIF Cookbook: Referenced Annotation", + "visible": true + }, + { + "@id": "https://iiif.wellcomecollection.org/presentation/b19974760_133_0018", + "@type": "sc:Manifest", + "label": "Chemist & Druggist", + "visible": true } ] } From d949225a04b0f0a6d32f36e8eae7c5ef1633e373 Mon Sep 17 00:00:00 2001 From: jamesmisson Date: Wed, 29 Oct 2025 15:30:41 +0000 Subject: [PATCH 2/9] first draft, display referenced annos --- .../TextRightPanel.ts | 463 +++++++++++++----- 1 file changed, 330 insertions(+), 133 deletions(-) diff --git a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts index 004d525f2..da7f1b881 100644 --- a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts +++ b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts @@ -5,12 +5,17 @@ import { Events } from "../../../../Events"; import OpenSeadragonExtension from "../../extensions/uv-openseadragon-extension/Extension"; import OpenSeadragon from "openseadragon"; import { Bools, Clipboard } from "../../Utils"; -import { IExternalImageResourceData } from "manifesto.js"; +import { + // AnnotationBody, + IExternalImageResourceData, +} from "manifesto.js"; import { OpenSeadragonCenterPanel } from "../../modules/uv-openseadragoncenterpanel-module/OpenSeadragonCenterPanel"; import { Shell } from "../uv-shared-module/Shell"; import { AnnotationRect } from "@iiif/manifold"; import { OpenSeadragonExtensionEvents } from "../../extensions/uv-openseadragon-extension/Events"; +import { AnnotationPage, Annotation, IManifestoOptions } from "manifesto.js"; + export class TextRightPanel extends RightPanel { $transcribedText: JQuery; $spinner: JQuery; @@ -90,100 +95,33 @@ export class TextRightPanel extends RightPanel { this.$top.append(this.$copyButton); } - function getIntersectionArea(rect1, rect2) { - const xOverlap = Math.max( - 0, - Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - - Math.max(rect1.x, rect2.x) - ); - const yOverlap = Math.max( - 0, - Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - - Math.max(rect1.y, rect2.y) - ); - return xOverlap * yOverlap; - } - - function getIntersectionPercentage(rect1, rect2) { - const intersectionArea = getIntersectionArea(rect1, rect2); - const rect1Area = rect1.width * rect1.height; - return (intersectionArea / rect1Area) * 100; - } - - function highlightSearchHit( - element: Element, - searchText: string, - index: string | number, - canvasIndex: string | number - ): void { - // traverse only text nodes - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, { - acceptNode: function (node: Node): number { - // skip text nodes that are already inside searchHitSpan elements - const parent = node.parentElement; - if (parent && parent.classList.contains("searchHitSpan")) { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - }, - }); - - let currentNode: Node | null; - const textNodes: Text[] = []; + // function getIntersectionArea(rect1, rect2) { + // const xOverlap = Math.max( + // 0, + // Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - + // Math.max(rect1.x, rect2.x) + // ); + // const yOverlap = Math.max( + // 0, + // Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - + // Math.max(rect1.y, rect2.y) + // ); + // return xOverlap * yOverlap; + // } + + // function getIntersectionPercentage(rect1, rect2) { + // const intersectionArea = getIntersectionArea(rect1, rect2); + // const rect1Area = rect1.width * rect1.height; + // return (intersectionArea / rect1Area) * 100; + // } - // collect all valid text nodes - while ((currentNode = walker.nextNode())) { - textNodes.push(currentNode as Text); - } - - // find the first occurrence of search hit - for (const textNode of textNodes) { - const textContent = textNode.textContent || ""; - const hitIndex = textContent.indexOf(searchText); - - if (hitIndex !== -1) { - // split the text node and wrap the match - const beforeText = textContent.substring(0, hitIndex); - const matchText = textContent.substring( - hitIndex, - hitIndex + searchText.length - ); - const afterText = textContent.substring(hitIndex + searchText.length); - - // create highlight span - const highlightSpan = document.createElement("span"); - highlightSpan.className = "searchHitSpan"; - highlightSpan.setAttribute("data-index", String(index)); - highlightSpan.setAttribute("data-canvas-index", String(canvasIndex)); - highlightSpan.textContent = matchText; - - const parent = textNode.parentNode; - - if (parent) { - // replace original text node with the parts - if (beforeText) { - const beforeNode = document.createTextNode(beforeText); - parent.insertBefore(beforeNode, textNode); - } - - parent.insertBefore(highlightSpan, textNode); - - if (afterText) { - const afterNode = document.createTextNode(afterText); - parent.insertBefore(afterNode, textNode); - } - - // remove the original text node - parent.removeChild(textNode); - } + this.extensionHost.on(Events.SEARCH_HIT_CHANGED, (e) => { + // this reacts to a new search hit being selected and styles the elements (rect on the canvas, span in the full text) appropriately - // stop after finding and wrapping the first occurrence - break; - } - } - } + // the e object has hitIndex, rectIndex, and canvasIndex for the search result that was just selected. + // rectIndex is the index of the rect on the canvas. hit index is the result index + // console.log(e); - this.extensionHost.on(Events.SEARCH_HIT_CHANGED, (e) => { this.currentRectIndex = e[0].rectIndex; const canvasIndex = this.extension.helper.canvasIndex; this.currentHitIndex = e[0].hitIndex; @@ -231,6 +169,7 @@ export class TextRightPanel extends RightPanel { this.extensionHost.on( OpenSeadragonExtensionEvents.CANVAS_CLICK, (e: any) => { + console.log(e.originalTarget); var target = e.originalTarget || e.originalEvent.target; $(target).trigger("click"); } @@ -260,7 +199,11 @@ export class TextRightPanel extends RightPanel { this.removeLineAnnotationRects(); for (let i = 0; i < canvases.length; i++) { const c = canvases[i]; + const seeAlso = c.getProperty("seeAlso"); + + const annotations = c.getAnnotations(); + let header; if (i === 0 && canvases.length > 1) { @@ -290,57 +233,60 @@ export class TextRightPanel extends RightPanel { this.$transcribedText.html(""); } - // We need to see if seeAlso contains an ALTO file and maybe allow for other HTR/OCR formats in the future - // and make sure which version of IIIF Presentation API is used - if (seeAlso.length === undefined) { + if (annotations.length) { + // Check for annotations on the canvas first + await this.processAnnotations(annotations, c.index, header); + } else if (seeAlso && seeAlso.length === undefined) { // This is IIIF Presentation API < 3 if (seeAlso.profile.includes("alto")) { await this.processAltoFile(seeAlso["@id"], c.index, header); } - } else { + } else if (seeAlso && seeAlso.length > 0) { // This is IIIF Presentation API >= 3 if (seeAlso[0].profile.includes("alto")) { await this.processAltoFile(seeAlso[0]["id"], c.index, header); } } - const annotationRects = (this.extension) - .getAnnotationRects() - .filter((rect) => { - return rect["canvasIndex"] == c.index; - }); - annotationRects.forEach((annotationRect) => { - const rect = { - x: annotationRect.x, - y: annotationRect.y, - width: annotationRect.width, - height: annotationRect.height, - }; - $("div.lineAnnotationRect").each( - (i: Number, lineAnnotationRect: any) => { - const x = $(lineAnnotationRect).data("x"); - const y = $(lineAnnotationRect).data("y"); - const width = $(lineAnnotationRect).data("width"); - const height = $(lineAnnotationRect).data("height"); - const lineRect = { x: x, y: y, width: width, height: height }; - - const p = getIntersectionPercentage(rect, lineRect); - if (p > 50) { - const lineElement = $( - "div#" + $(lineAnnotationRect).attr("id") + ".lineAnnotation" - ); - if (lineElement[0]) { - highlightSearchHit( - lineElement[0], - annotationRect.chars, - annotationRect.index, - annotationRect.canvasIndex - ); - } - } - } - ); - }); + // const annotationRects = (this.extension) + // .getAnnotationRects() + // .filter((rect) => { + // return rect["canvasIndex"] == c.index; + // }); + + // annotationRects.forEach((annotationRect) => { + // const rect = { + // x: annotationRect.x, + // y: annotationRect.y, + // width: annotationRect.width, + // height: annotationRect.height, + // }; + + // $("div.lineAnnotationRect").each( + // (i: Number, lineAnnotationRect: any) => { + // const x = $(lineAnnotationRect).data("x"); + // const y = $(lineAnnotationRect).data("y"); + // const width = $(lineAnnotationRect).data("width"); + // const height = $(lineAnnotationRect).data("height"); + // const lineRect = { x: x, y: y, width: width, height: height }; + + // const p = getIntersectionPercentage(rect, lineRect); + // if (p > 50) { + // const lineElement = $( + // "div#" + $(lineAnnotationRect).attr("id") + ".lineAnnotation" + // ); + // if (lineElement[0]) { + // this.highlightSearchHit( + // lineElement[0], + // annotationRect.chars, + // annotationRect.index, + // annotationRect.canvasIndex + // ); + // } + // } + // } + // ); + // }); if ( $( @@ -384,6 +330,79 @@ export class TextRightPanel extends RightPanel { this.$top.parent().addClass("textRightPanel"); } + // highlightSearchHit( + // element: Element, + // searchText: string, + // index: string | number, + // canvasIndex: string | number + // ): void { + // // traverse only text nodes + // const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, { + // acceptNode: function (node: Node): number { + // // skip text nodes that are already inside searchHitSpan elements + // const parent = node.parentElement; + // if (parent && parent.classList.contains("searchHitSpan")) { + // return NodeFilter.FILTER_REJECT; + // } + // return NodeFilter.FILTER_ACCEPT; + // }, + // }); + + // let currentNode: Node | null; + // const textNodes: Text[] = []; + + // // collect all valid text nodes + // while ((currentNode = walker.nextNode())) { + // textNodes.push(currentNode as Text); + // } + + // // find the first occurrence of search hit + // for (const textNode of textNodes) { + // const textContent = textNode.textContent || ""; + // const hitIndex = textContent.indexOf(searchText); + + // if (hitIndex !== -1) { + // // split the text node and wrap the match + // const beforeText = textContent.substring(0, hitIndex); + // const matchText = textContent.substring( + // hitIndex, + // hitIndex + searchText.length + // ); + // const afterText = textContent.substring(hitIndex + searchText.length); + + // // create highlight span + // const highlightSpan = document.createElement("span"); + // highlightSpan.className = "searchHitSpan"; + // highlightSpan.setAttribute("data-index", String(index)); + // highlightSpan.setAttribute("data-canvas-index", String(canvasIndex)); + // highlightSpan.textContent = matchText; + + // const parent = textNode.parentNode; + + // if (parent) { + // // replace original text node with the parts + // if (beforeText) { + // const beforeNode = document.createTextNode(beforeText); + // parent.insertBefore(beforeNode, textNode); + // } + + // parent.insertBefore(highlightSpan, textNode); + + // if (afterText) { + // const afterNode = document.createTextNode(afterText); + // parent.insertBefore(afterNode, textNode); + // } + + // // remove the original text node + // parent.removeChild(textNode); + // } + + // // stop after finding and wrapping the first occurrence + // break; + // } + // } + // } + toggleFinish(): void { super.toggleFinish(); } @@ -430,6 +449,8 @@ export class TextRightPanel extends RightPanel { x + this.offsetX + (this.index > 0 ? this.centerPanel.config.options.pageGap : 0); + + //JM this has a hardcoded space between tokens so only works with XML that has a space as a token delimiter. Is this all ALTO? const text = t.join(" "); this.clipboardText += text + " "; @@ -550,6 +571,181 @@ export class TextRightPanel extends RightPanel { } }; + // JM this function will do the same as processAltoFile but for w3c annotations + processAnnotations = async ( + annotations: AnnotationPage[], + canvasIndex, + header? + ): Promise => { + this.$spinner = $('
'); + this.$spinner.css( + "top", + this.$main.height() / 2 - this.$spinner.height() / 2 + ); + this.$main.append(this.$spinner); + this.$spinner.show(); + + try { + // Iterate through each annotation page reference + //need some logic here to check whether references are embedded or referenced + for (const annotationPageRef of annotations) { + const annotationPageId = annotationPageRef.id; + + if (annotationPageId) { + const response = await fetch(annotationPageId); + const annotationPageData = await response.json(); + + // Create an AnnotationPage instance from the incoming JSON data + const options: IManifestoOptions = { + locale: this.extension.helper.options.locale, + }; + + const annotationPage: AnnotationPage = new AnnotationPage( + annotationPageData, + options + ); + + const annotations: Annotation[] = annotationPage.getAnnotations(); + + const lines = Array.from(annotations).map((a, i) => { + const bodies = a.getBody(); + if (bodies && bodies.length > 0) { + const body = bodies[0]; + const text = body.getValue(); + const [baseX, y, width, height] = + a?.getTarget()?.split("#xywh=")[1]?.split(",").map(Number) || + []; + const x = + baseX + + this.offsetX + + (this.index > 0 ? this.centerPanel.config.options.pageGap : 0); + + //JM then the rest of this processAnnotations is the same as processAltoFile so can refactor this out as a function to share between them + const line = $( + '
' + + text + + "
" + ); + + if (!this.extension.isMobile()) { + const div = $( + '
' + ); + $(div).on("keydown", (e: any) => { + if (e.keyCode === 13) { + $(e.target).trigger("click"); + } + }); + $(div).on("click", (e: any) => { + const canvasIndex = Number( + e.target.getAttribute("id").split("-")[2] + ); + // We change the current canvas index to the clicked page (if we're in two page view) + if (canvasIndex !== this.currentCanvasIndex) { + this.extension.helper.canvasIndex = canvasIndex; + this.currentCanvasIndex = canvasIndex; + } + this.clearLineAnnotationRects(); + this.clearLineAnnotations(); + this.setCurrentLineAnnotation(e.target, true); + this.setCurrentLineAnnotationRect(e.target); + }); + // Add overlay to OpenSeadragon canvas + const osRect = new OpenSeadragon.Rect(x, y, width, height); + (( + this.extension + )).centerPanel.viewer.addOverlay(div[0], osRect); + + line.on("keydown", (e: any) => { + if (e.keyCode === 13) { + $(e.target).trigger("click"); + } + }); + // Sync line click with line annotation + line.on("click", (e: any) => { + const target = e.currentTarget; + const canvasIndex = Number( + target.getAttribute("id").split("-")[2] + ); + // We change the current canvas index to the clicked page (if we're in two page view) + if (canvasIndex !== this.currentCanvasIndex) { + this.extension.helper.canvasIndex = canvasIndex; + this.currentCanvasIndex = canvasIndex; + } + this.clearLineAnnotationRects(); + this.clearLineAnnotations(); + this.setCurrentLineAnnotation(target, false); + this.setCurrentLineAnnotationRect(target); + }); + } + return line; + } + }); + + if (!this.$transcribedText) { + this.$transcribedText = $('
'); + } + if (header) { + this.$transcribedText.append( + $('
' + header + "
") + ); + } + if (lines.length > 0) { + this.$transcribedText.append(lines); + this.$transcribedText.attr( + "data-text", + this.clipboardText.trimEnd() + ); + } else { + this.$transcribedText.append( + $("
" + this.content.textNotFound + "
") + ); + } + + if ( + this.$transcribedText[0]?.firstElementChild?.firstChild + ?.toString() + .trim() + ) { + this.$spinner.hide(); + } + + this.$main.append(this.$transcribedText); + + // If we already have a selected line annotation, make sure it's selected again after load + if (this.$existingAnnotation[0] !== undefined) { + const id = $(this.$existingAnnotation).attr("id"); + if ($("div#" + id).length > 0) { + // Make sure the line annotation exists in the DOM + this.setCurrentLineAnnotation($("div#" + id)[0], true); + this.setCurrentLineAnnotationRect($("div#" + id)[0]); + } + } + } + } + } catch (error) { + console.error("Error fetching annotations:", error); + } + }; + copyText(text: string): void { Clipboard.copy(text); @@ -607,6 +803,7 @@ export class TextRightPanel extends RightPanel { $("div.lineAnnotationRect").remove(); } + //this styles the annotation on the OSD canvas. BUT it doesn't bring that rect into view if it's currently off screen. do we want that? setCurrentAnnotation(canvasIndex: any, index: any): void { $(".annotationRect").each((i: number, annotation: any) => { if ($(annotation).hasClass("current")) { From df3c5ef19a409ce6dc5f6f581080435f97582b88 Mon Sep 17 00:00:00 2001 From: jamesmisson Date: Wed, 29 Oct 2025 16:31:11 +0000 Subject: [PATCH 3/9] refactor processAlto, processAnnotations --- .../TextRightPanel.ts | 490 ++++++++---------- 1 file changed, 205 insertions(+), 285 deletions(-) diff --git a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts index da7f1b881..503438059 100644 --- a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts +++ b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts @@ -15,7 +15,13 @@ import { AnnotationRect } from "@iiif/manifold"; import { OpenSeadragonExtensionEvents } from "../../extensions/uv-openseadragon-extension/Events"; import { AnnotationPage, Annotation, IManifestoOptions } from "manifesto.js"; - +interface LineData { + text: string; + x: number; + y: number; + width: number; + height: number; +} export class TextRightPanel extends RightPanel { $transcribedText: JQuery; $spinner: JQuery; @@ -235,7 +241,7 @@ export class TextRightPanel extends RightPanel { if (annotations.length) { // Check for annotations on the canvas first - await this.processAnnotations(annotations, c.index, header); + await this.processWebAnnotations(annotations, c.index, header); } else if (seeAlso && seeAlso.length === undefined) { // This is IIIF Presentation API < 3 if (seeAlso.profile.includes("alto")) { @@ -421,162 +427,169 @@ export class TextRightPanel extends RightPanel { }); */ } - // Let's load the ALTO file and do some parsing - processAltoFile = async (altoUrl, canvasIndex, header?): Promise => { - this.$spinner = $('
'); - this.$spinner.css( - "top", - this.$main.height() / 2 - this.$spinner.height() / 2 - ); - this.$main.append(this.$spinner); - this.$spinner.show(); - try { - const response = await fetch(altoUrl); - const data = await response.text(); - const altoDoc = new DOMParser().parseFromString(data, "application/xml"); - const textLines = altoDoc.querySelectorAll("TextLine"); - - const lines = Array.from(textLines).map((e, i) => { - const strings = e.querySelectorAll("String"); - const t = Array.from(strings).map((e, i) => { - return e.getAttribute("CONTENT"); - }); - let x = Number(e.getAttribute("HPOS")); - const y = Number(e.getAttribute("VPOS")); - const width = Number(e.getAttribute("WIDTH")); - const height = Number(e.getAttribute("HEIGHT")); - x = - x + + private extractAltoData(altoDoc: Document): LineData[] { + const textLines = altoDoc.querySelectorAll("TextLine"); + + return Array.from(textLines).map((e) => { + const strings = e.querySelectorAll("String"); + const t = Array.from(strings).map((s) => s.getAttribute("CONTENT")); + const text = t.join(" "); + + let x = Number(e.getAttribute("HPOS")); + const y = Number(e.getAttribute("VPOS")); + const width = Number(e.getAttribute("WIDTH")); + const height = Number(e.getAttribute("HEIGHT")); + + x = + x + + this.offsetX + + (this.index > 0 ? this.centerPanel.config.options.pageGap : 0); + + this.clipboardText += text + " "; + + return { text, x, y, width, height }; + }); + } + + private extractWebAnnotationData(annotations: Annotation[]): LineData[] { + console.log("processing ", annotations); + return annotations + .map((a) => { + const bodies = a.getBody(); + if (!bodies || bodies.length === 0) return null; + + const body = bodies[0]; + const text = body.getValue(); + const target = a?.getTarget(); + + if (!target) return null; + + const xywh = target.split("#xywh=")[1]; + + let baseX: number, y: number, width: number, height: number; + + if (!xywh) { + // Target is the whole canvas - use canvas dimensions + const canvas = this.extension.helper.getCurrentCanvas(); + if (!canvas) return null; + + baseX = 0; + y = 0; + width = 0; + height = 0; + } else { + [baseX, y, width, height] = xywh.split(",").map(Number); + } + + const x = + baseX + this.offsetX + (this.index > 0 ? this.centerPanel.config.options.pageGap : 0); - //JM this has a hardcoded space between tokens so only works with XML that has a space as a token delimiter. Is this all ALTO? - const text = t.join(" "); this.clipboardText += text + " "; - const line = $( - '
' + - text + - "
" - ); + return { text, x, y, width, height }; + }) + .filter((line): line is LineData => line !== null); + } - if (!this.extension.isMobile()) { - const div = $( - '
' - ); - $(div).on("keydown", (e: any) => { - if (e.keyCode === 13) { - $(e.target).trigger("click"); - } - }); - $(div).on("click", (e: any) => { - const canvasIndex = Number( - e.target.getAttribute("id").split("-")[2] - ); - // We change the current canvas index to the clicked page (if we're in two page view) - if (canvasIndex !== this.currentCanvasIndex) { - this.extension.helper.canvasIndex = canvasIndex; - this.currentCanvasIndex = canvasIndex; - } - this.clearLineAnnotationRects(); - this.clearLineAnnotations(); - this.setCurrentLineAnnotation(e.target, true); - this.setCurrentLineAnnotationRect(e.target); - }); - // Add overlay to OpenSeadragon canvas - const osRect = new OpenSeadragon.Rect(x, y, width, height); - (( - this.extension - )).centerPanel.viewer.addOverlay(div[0], osRect); - - line.on("keydown", (e: any) => { - if (e.keyCode === 13) { - $(e.target).trigger("click"); - } - }); - // Sync line click with line annotation - line.on("click", (e: any) => { - const target = e.currentTarget; - const canvasIndex = Number(target.getAttribute("id").split("-")[2]); - // We change the current canvas index to the clicked page (if we're in two page view) - if (canvasIndex !== this.currentCanvasIndex) { - this.extension.helper.canvasIndex = canvasIndex; - this.currentCanvasIndex = canvasIndex; - } - this.clearLineAnnotationRects(); - this.clearLineAnnotations(); - this.setCurrentLineAnnotation(target, false); - this.setCurrentLineAnnotationRect(target); - }); - } - return line; - }); + private createLineElements( + lineDataArray: LineData[], + canvasIndex: number + ): JQuery[] { + return lineDataArray.map((lineData, i) => { + const { text, x, y, width, height } = lineData; - if (!this.$transcribedText) { - this.$transcribedText = $('
'); - } - if (header) { - this.$transcribedText.append( - $('
' + header + "
") + const line = $( + `
${text}
` + ); + + if (!this.extension.isMobile()) { + // Create overlay rectangle + const div = $( + `
` ); - } - if (lines.length > 0) { - this.$transcribedText.append(lines); - this.$transcribedText.attr("data-text", this.clipboardText.trimEnd()); - } else { - this.$transcribedText.append( - $("
" + this.content.textNotFound + "
") + + this.attachLineEventHandlers(div, line); + + // Add overlay to OpenSeadragon canvas + const osRect = new OpenSeadragon.Rect(x, y, width, height); + (this.extension).centerPanel.viewer.addOverlay( + div[0], + osRect ); } - if ( - this.$transcribedText[0]?.firstElementChild?.firstChild - ?.toString() - .trim() - ) { - this.$spinner.hide(); + return line; + }); + } + + private attachLineEventHandlers(div: JQuery, line: JQuery): void { + const handleClick = (target: HTMLElement) => { + const canvasIndex = Number(target.getAttribute("id")!.split("-")[2]); + if (canvasIndex !== this.currentCanvasIndex) { + this.extension.helper.canvasIndex = canvasIndex; + this.currentCanvasIndex = canvasIndex; } - this.$main.append(this.$transcribedText); + this.clearLineAnnotationRects(); + this.clearLineAnnotations(); + this.setCurrentLineAnnotation(target, true); + this.setCurrentLineAnnotationRect(target); + }; - // If we already have a selected line annotation, make sure it's selected again after load - if (this.$existingAnnotation[0] !== undefined) { - const id = $(this.$existingAnnotation).attr("id"); - if ($("div#" + id).length > 0) { - // Make sure the line annotation exists in the DOM - this.setCurrentLineAnnotation($("div#" + id)[0], true); - this.setCurrentLineAnnotationRect($("div#" + id)[0]); - } + // Div (overlay) handlers + div.on("keydown", (e: any) => { + if (e.keyCode === 13) $(e.target).trigger("click"); + }); + div.on("click", (e: any) => handleClick(e.target)); + + // Line (text) handlers + line.on("keydown", (e: any) => { + if (e.keyCode === 13) $(e.target).trigger("click"); + }); + line.on("click", (e: any) => handleClick(e.currentTarget)); + } + + private renderTranscribedText(lines: JQuery[], header?: string): void { + if (!this.$transcribedText) { + this.$transcribedText = $('
'); + } + + if (header) { + this.$transcribedText.append($(`
${header}
`)); + } + + if (lines.length > 0) { + this.$transcribedText.append(lines); + this.$transcribedText.attr("data-text", this.clipboardText.trimEnd()); + } else { + this.$transcribedText.append( + $(`
${this.content.textNotFound}
`) + ); + } + + if ( + this.$transcribedText[0]?.firstElementChild?.firstChild?.toString().trim() + ) { + this.$spinner.hide(); + } + + this.$main.append(this.$transcribedText); + + // Restore previously selected annotation + if (this.$existingAnnotation[0] !== undefined) { + const id = $(this.$existingAnnotation).attr("id"); + if ($("div#" + id).length > 0) { + this.setCurrentLineAnnotation($("div#" + id)[0], true); + this.setCurrentLineAnnotationRect($("div#" + id)[0]); } - } catch (error) { - throw new Error("Unable to fetch Alto file: " + error.message); } - }; + } - // JM this function will do the same as processAltoFile but for w3c annotations - processAnnotations = async ( - annotations: AnnotationPage[], - canvasIndex, - header? - ): Promise => { + private showSpinner(): void { this.$spinner = $('
'); this.$spinner.css( "top", @@ -584,165 +597,72 @@ export class TextRightPanel extends RightPanel { ); this.$main.append(this.$spinner); this.$spinner.show(); + } + + processAltoFile = async ( + altoUrl: string, + canvasIndex: number, + header?: string + ): Promise => { + this.showSpinner(); + + try { + const response = await fetch(altoUrl); + const data = await response.text(); + const altoDoc = new DOMParser().parseFromString(data, "application/xml"); + + const lineDataArray = this.extractAltoData(altoDoc); + const lines = this.createLineElements(lineDataArray, canvasIndex); + + this.renderTranscribedText(lines, header); + } catch (error) { + throw new Error("Unable to fetch Alto file: " + error.message); + } + }; + + processWebAnnotations = async ( + annotations: AnnotationPage[], + canvasIndex: number, + header?: string + ): Promise => { + this.showSpinner(); try { - // Iterate through each annotation page reference - //need some logic here to check whether references are embedded or referenced for (const annotationPageRef of annotations) { - const annotationPageId = annotationPageRef.id; + let annotationPage: AnnotationPage; + + // Check if annotations are embedded or referenced + const embeddedAnnotations = annotationPageRef.getAnnotations(); - if (annotationPageId) { - const response = await fetch(annotationPageId); + if (embeddedAnnotations && embeddedAnnotations.length > 0) { + // Annotations are embedded + annotationPage = annotationPageRef; + } else if (annotationPageRef.id) { + // Annotations are referenced + const response = await fetch(annotationPageRef.id); const annotationPageData = await response.json(); - // Create an AnnotationPage instance from the incoming JSON data const options: IManifestoOptions = { - locale: this.extension.helper.options.locale, + locale: this.extension.helper.options.locale ?? "en-GB", }; - const annotationPage: AnnotationPage = new AnnotationPage( - annotationPageData, - options - ); - - const annotations: Annotation[] = annotationPage.getAnnotations(); - - const lines = Array.from(annotations).map((a, i) => { - const bodies = a.getBody(); - if (bodies && bodies.length > 0) { - const body = bodies[0]; - const text = body.getValue(); - const [baseX, y, width, height] = - a?.getTarget()?.split("#xywh=")[1]?.split(",").map(Number) || - []; - const x = - baseX + - this.offsetX + - (this.index > 0 ? this.centerPanel.config.options.pageGap : 0); - - //JM then the rest of this processAnnotations is the same as processAltoFile so can refactor this out as a function to share between them - const line = $( - '
' + - text + - "
" - ); - - if (!this.extension.isMobile()) { - const div = $( - '
' - ); - $(div).on("keydown", (e: any) => { - if (e.keyCode === 13) { - $(e.target).trigger("click"); - } - }); - $(div).on("click", (e: any) => { - const canvasIndex = Number( - e.target.getAttribute("id").split("-")[2] - ); - // We change the current canvas index to the clicked page (if we're in two page view) - if (canvasIndex !== this.currentCanvasIndex) { - this.extension.helper.canvasIndex = canvasIndex; - this.currentCanvasIndex = canvasIndex; - } - this.clearLineAnnotationRects(); - this.clearLineAnnotations(); - this.setCurrentLineAnnotation(e.target, true); - this.setCurrentLineAnnotationRect(e.target); - }); - // Add overlay to OpenSeadragon canvas - const osRect = new OpenSeadragon.Rect(x, y, width, height); - (( - this.extension - )).centerPanel.viewer.addOverlay(div[0], osRect); - - line.on("keydown", (e: any) => { - if (e.keyCode === 13) { - $(e.target).trigger("click"); - } - }); - // Sync line click with line annotation - line.on("click", (e: any) => { - const target = e.currentTarget; - const canvasIndex = Number( - target.getAttribute("id").split("-")[2] - ); - // We change the current canvas index to the clicked page (if we're in two page view) - if (canvasIndex !== this.currentCanvasIndex) { - this.extension.helper.canvasIndex = canvasIndex; - this.currentCanvasIndex = canvasIndex; - } - this.clearLineAnnotationRects(); - this.clearLineAnnotations(); - this.setCurrentLineAnnotation(target, false); - this.setCurrentLineAnnotationRect(target); - }); - } - return line; - } - }); - - if (!this.$transcribedText) { - this.$transcribedText = $('
'); - } - if (header) { - this.$transcribedText.append( - $('
' + header + "
") - ); - } - if (lines.length > 0) { - this.$transcribedText.append(lines); - this.$transcribedText.attr( - "data-text", - this.clipboardText.trimEnd() - ); - } else { - this.$transcribedText.append( - $("
" + this.content.textNotFound + "
") - ); - } + annotationPage = new AnnotationPage(annotationPageData, options); + } else { + // No annotations + continue; + } - if ( - this.$transcribedText[0]?.firstElementChild?.firstChild - ?.toString() - .trim() - ) { - this.$spinner.hide(); - } + const annotationsList = annotationPage.getAnnotations(); - this.$main.append(this.$transcribedText); + if (annotationsList && annotationsList.length > 0) { + const lineDataArray = this.extractWebAnnotationData(annotationsList); + const lines = this.createLineElements(lineDataArray, canvasIndex); - // If we already have a selected line annotation, make sure it's selected again after load - if (this.$existingAnnotation[0] !== undefined) { - const id = $(this.$existingAnnotation).attr("id"); - if ($("div#" + id).length > 0) { - // Make sure the line annotation exists in the DOM - this.setCurrentLineAnnotation($("div#" + id)[0], true); - this.setCurrentLineAnnotationRect($("div#" + id)[0]); - } - } + this.renderTranscribedText(lines, header); } } } catch (error) { - console.error("Error fetching annotations:", error); + console.error("Error processing annotations:", error); } }; From 4cb0e857e6080e829907fa55b955bdaa82ab89fa Mon Sep 17 00:00:00 2001 From: jamesmisson Date: Wed, 29 Oct 2025 16:32:45 +0000 Subject: [PATCH 4/9] add example --- src/iiif-collection.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/iiif-collection.json b/src/iiif-collection.json index bd759abfb..af1c67428 100644 --- a/src/iiif-collection.json +++ b/src/iiif-collection.json @@ -1203,6 +1203,12 @@ "@type": "sc:Manifest", "label": "Chemist & Druggist", "visible": true + }, + { + "@id": "https://digital.lib.utk.edu/assemble/manifest/insurancena/125", + "@type": "sc:Manifest", + "label": "Letter, Elliston Perot & John Perot (full canvas annos)", + "visible": true } ] } From 2f19e053c9b7ae6218f532a802e42fe108ead863 Mon Sep 17 00:00:00 2001 From: jamesmisson Date: Wed, 29 Oct 2025 16:51:37 +0000 Subject: [PATCH 5/9] use jquery method to add text to elements --- .../TextRightPanel.ts | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts index 503438059..dbe1d7b30 100644 --- a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts +++ b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts @@ -5,10 +5,7 @@ import { Events } from "../../../../Events"; import OpenSeadragonExtension from "../../extensions/uv-openseadragon-extension/Extension"; import OpenSeadragon from "openseadragon"; import { Bools, Clipboard } from "../../Utils"; -import { - // AnnotationBody, - IExternalImageResourceData, -} from "manifesto.js"; +import { IExternalImageResourceData } from "manifesto.js"; import { OpenSeadragonCenterPanel } from "../../modules/uv-openseadragoncenterpanel-module/OpenSeadragonCenterPanel"; import { Shell } from "../uv-shared-module/Shell"; import { AnnotationRect } from "@iiif/manifold"; @@ -419,12 +416,6 @@ export class TextRightPanel extends RightPanel { this.$main.height( this.$element.height() - this.$top.height() - this.$main.verticalMargins() ); - - /* this.$element.css({ - left: Math.floor( - this.$element.parent().width() - this.$element.outerWidth() - this.options.panelCollapsedWidth - ), - }); */ } private extractAltoData(altoDoc: Document): LineData[] { @@ -469,10 +460,7 @@ export class TextRightPanel extends RightPanel { let baseX: number, y: number, width: number, height: number; if (!xywh) { - // Target is the whole canvas - use canvas dimensions - const canvas = this.extension.helper.getCurrentCanvas(); - if (!canvas) return null; - + // Target is the whole canvas - give 0 dimension. baseX = 0; y = 0; width = 0; @@ -501,16 +489,16 @@ export class TextRightPanel extends RightPanel { const { text, x, y, width, height } = lineData; const line = $( - `
${text}
` - ); + `
` + ).text(text); if (!this.extension.isMobile()) { // Create overlay rectangle const div = $( `
` - ); + ).attr("title", text); this.attachLineEventHandlers(div, line); From 2317a05c6b18e8f7a89ba357c4cbd1339b3fc9f6 Mon Sep 17 00:00:00 2001 From: jamesmisson Date: Wed, 29 Oct 2025 16:53:54 +0000 Subject: [PATCH 6/9] handle alto fetch error --- .../modules/uv-textrightpanel-module/TextRightPanel.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts index dbe1d7b30..c4cc45967 100644 --- a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts +++ b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts @@ -604,7 +604,13 @@ export class TextRightPanel extends RightPanel { this.renderTranscribedText(lines, header); } catch (error) { - throw new Error("Unable to fetch Alto file: " + error.message); + console.error("Unable to fetch Alto file:", error); + this.$spinner.hide(); + this.$transcribedText = $('
'); + this.$transcribedText.append( + $(`
${this.content.textNotFound}
`) + ); + this.$main.append(this.$transcribedText); } }; From cea5b72214366fe8471f5cc4626febc586d4c396 Mon Sep 17 00:00:00 2001 From: jamesmisson Date: Thu, 30 Oct 2025 10:56:17 +0000 Subject: [PATCH 7/9] first draft --- .../uv-openseadragon-extension/Extension.ts | 235 +++++++++++++++++- .../SearchLeftPanel.ts | 9 +- 2 files changed, 238 insertions(+), 6 deletions(-) diff --git a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts index ec61b9db8..593228e7d 100644 --- a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts +++ b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts @@ -9,7 +9,6 @@ import DownloadDialogue from "./DownloadDialogue"; import { OpenSeadragonExtensionEvents } from "./Events"; import { ExternalContentDialogue } from "../../modules/uv-dialogues-module/ExternalContentDialogue"; import { FooterPanel as MobileFooterPanel } from "../../modules/uv-osdmobilefooterpanel-module/MobileFooter"; -// import { FooterPanel } from "../../modules/uv-searchfooterpanel-module/FooterPanel"; import { FooterPanel } from "../../modules/uv-shared-module/FooterPanel"; import { HelpDialogue } from "../../modules/uv-dialogues-module/HelpDialogue"; import { IOpenSeadragonExtensionData } from "./IOpenSeadragonExtensionData"; @@ -894,6 +893,91 @@ export default class OpenSeadragonExtension extends BaseExtension { return groupedAnnotations; } + groupWebAnnotationResultsByTarget(searchResults: any): AnnotationGroup[] { + const groupedAnnotations: AnnotationGroup[] = []; + + //we need to sort the items by canvas and position first, so that they appear in reading order + const sortedItems = [...searchResults.items].sort((a, b) => { + const canvasIdA = a.target.id.match(/(.*)#/)?.[1]; + const canvasIdB = b.target.id.match(/(.*)#/)?.[1]; + + if (!canvasIdA || !canvasIdB) return 0; + + const canvasIndexA = this.helper.getCanvasIndexById(canvasIdA); + const canvasIndexB = this.helper.getCanvasIndexById(canvasIdB); + + if (canvasIndexA === null || canvasIndexB === null) return 0; + + // First sort by canvas index + const canvasDiff = canvasIndexA - canvasIndexB; + if (canvasDiff !== 0) return canvasDiff; + + // Then sort by position within canvas + const boundsMatchA = a.target.id.match(/#(xywh=.+)$/); + const boundsMatchB = b.target.id.match(/#(xywh=.+)$/); + + if (boundsMatchA && boundsMatchB) { + try { + const boundsA = XYWHFragment.fromString(boundsMatchA[1]); + const boundsB = XYWHFragment.fromString(boundsMatchB[1]); + + const yDiff = boundsA.y - boundsB.y; + if (yDiff !== 0) return yDiff; + return boundsA.x - boundsB.x; + } catch (error) { + console.warn("Failed to parse bounds for sorting:", error); + } + } + + return 0; + }); + + for (const item of sortedItems) { + // Extract canvas ID from the target.id (everything before the #) + const canvasId = item.target.id.match(/(.*)#/)?.[1]; + if (!canvasId) continue; + + // Get canvas index, skip if null + const canvasIndex = this.helper.getCanvasIndexById(canvasId); + if (canvasIndex === null) continue; + + // Check if we already have an annotation group for this canvas + const existingGroup = groupedAnnotations.find( + (group) => group.canvasId === canvasId + ); + + // Transform W3C annotation to match AnnotationRect constructor expectations + // This is to get around Manifold's current way of handling w3c annos, which doesn't fit with content search 2 results + // but as it may be used elsewhere, don't want to change Manifold till looking at it properly + // related to this is the pre-existing groupWebAnnotationsByTarget function. groupWebAnnotationResultsByTarget could potentially + // replace that function but need to check it wouldn't break anything. + const transformedItem = { + target: item.target.id, // Convert object.id to string + bodyValue: item.body?.value || "", // Convert body.value to bodyValue + }; + + if (existingGroup) { + // Add rect to existing group + existingGroup.addRect(transformedItem); + } else { + // Create new annotation group + const annotationGroup = new AnnotationGroup(canvasId); + annotationGroup.canvasIndex = canvasIndex; + annotationGroup.addRect(transformedItem); + groupedAnnotations.push(annotationGroup); + } + } + + // Sort by canvas index + groupedAnnotations.sort((a, b) => { + return a.canvasIndex - b.canvasIndex; + }); + + console.log(groupedAnnotations); + + return groupedAnnotations; + } + groupSearchHitsByTarget(searchHits: any): SearchHit[] { const groupedSearchHits: SearchHit[] = []; let currentIndex = 0; @@ -945,6 +1029,144 @@ export default class OpenSeadragonExtension extends BaseExtension { return groupedSearchHits; } + //JM SNA uses the function above, groupSearchHitsByTarget, to process the response from content search 1. This function below does the same thing but for content search 2 + // groupSearchHitsByTarget currently sorts hits by canvas index only, so hits get out of order when there are multiple on a page. + // this function uses xwyh to sort by position on canvas too, so results are in reading order (if language is top to bottom, left to right!!), so this should also be applied to groupSearchHitsByTarget + sortWebAnnotationsSearchHits(searchResults: any): SearchHit[] { + const groupedSearchHits: SearchHit[] = []; + let currentIndex = 0; + let oldCanvasIndex: number | null = null; + + // Create a map of source annotation ID to canvas info for quick lookup + const sourceAnnotationMap = new Map< + string, + { canvasId: string; canvasIndex: number; bounds: XYWHFragment } + >(); + + // Process the main items to build the lookup map + for (const item of searchResults.items) { + const canvasId = item.target.id.match(/(.*)#/)?.[1]; + const boundsMatch = item.target.id.match(/#(xywh=.+)$/); + + if (canvasId && boundsMatch) { + const canvasIndex = this.helper.getCanvasIndexById(canvasId); + if (canvasIndex !== null) { + try { + const bounds = XYWHFragment.fromString(boundsMatch[1]); + sourceAnnotationMap.set(item.id, { + canvasId, + canvasIndex, + bounds, + }); + } catch (error) { + // Skip items with invalid bounds format + console.warn( + `Invalid bounds format for annotation ${item.id}:`, + boundsMatch[1] + ); + } + } + } + } + + // Process highlighting annotations if they exist + if (searchResults.annotations && searchResults.annotations.length > 0) { + const highlightingAnnotations = searchResults.annotations[0].items || []; + + // Sort highlighting annotations by canvas index, then coordinates to ensure correct order + highlightingAnnotations.sort((a, b) => { + const sourceA = sourceAnnotationMap.get(a.target.source); + const sourceB = sourceAnnotationMap.get(b.target.source); + if (!sourceA || !sourceB) return 0; + + // sort by canvas index + const canvasDiff = sourceA.canvasIndex - sourceB.canvasIndex; + if (canvasDiff !== 0) return canvasDiff; + + // sort by spatial position within canvas (top to bottom, left to right) + const yDiff = sourceA.bounds.y - sourceB.bounds.y; + if (yDiff !== 0) return yDiff; + return sourceA.bounds.x - sourceB.bounds.x; + }); + + for (const highlightAnnotation of highlightingAnnotations) { + const sourceInfo = sourceAnnotationMap.get( + highlightAnnotation.target.source + ); + + if (!sourceInfo) continue; + + const { canvasId, canvasIndex } = sourceInfo; + + // Handle canvas index tracking for grouping + if (canvasIndex !== oldCanvasIndex) { + currentIndex = 0; + oldCanvasIndex = canvasIndex; + } else { + currentIndex++; + } + + // Extract match details from the TextQuoteSelector + const selector = highlightAnnotation.target.selector?.[0]; + if (selector && selector.type === "TextQuoteSelector") { + const searchHit = new SearchHit(); + searchHit.canvasId = canvasId; + searchHit.canvasIndex = canvasIndex; + searchHit.before = selector.prefix || ""; + searchHit.after = selector.suffix || ""; + searchHit.match = selector.exact || ""; + searchHit.index = currentIndex; + + groupedSearchHits.push(searchHit); + } + } + } else { + // if no highlighting annotations, process main items directly + // handles cases where the search service doesn't provide separate highlighting annotations + const sortedItems = [...searchResults.items].sort((a, b) => { + const canvasIdA = a.target.id.match(/(.*)#/)?.[1]; + const canvasIdB = b.target.id.match(/(.*)#/)?.[1]; + if (!canvasIdA || !canvasIdB) return 0; + + const indexA = this.helper.getCanvasIndexById(canvasIdA); + const indexB = this.helper.getCanvasIndexById(canvasIdB); + + // Handle null values - treat null as -1 to sort them to the beginning + const safeIndexA = indexA ?? -1; + const safeIndexB = indexB ?? -1; + + return safeIndexA - safeIndexB; + }); + + for (const item of sortedItems) { + const canvasId = item.target.id.match(/(.*)#/)?.[1]; + if (!canvasId) continue; + + const canvasIndex = this.helper.getCanvasIndexById(canvasId); + if (canvasIndex === null) continue; // Skip items with invalid canvas indices + + if (canvasIndex !== oldCanvasIndex) { + currentIndex = 0; + oldCanvasIndex = canvasIndex; + } else { + currentIndex++; + } + + const searchHit = new SearchHit(); + searchHit.canvasId = canvasId; + searchHit.canvasIndex = canvasIndex; + searchHit.before = ""; + searchHit.after = ""; + searchHit.match = item.body?.value || ""; + searchHit.index = currentIndex; + + groupedSearchHits.push(searchHit); + } + } + + return groupedSearchHits; + } + checkForSearchParam(): void { // if a highlight param is set, use it to search. const highlight: string | undefined = (( @@ -1584,12 +1806,23 @@ export default class OpenSeadragonExtension extends BaseExtension { .then((response) => response.json()) .then((results) => { if (results.resources && results.resources.length) { + // this works for content search api 1 searchResults = searchResults.concat( this.groupOpenAnnotationsByTarget(results) ); searchHits = searchHits.concat(this.groupSearchHitsByTarget(results)); + } else if (results.items && results.items.length) { + //this will work for content search api 2 + searchResults = searchResults.concat( + this.groupWebAnnotationResultsByTarget(results) + ); + searchHits = searchHits.concat( + this.sortWebAnnotationsSearchHits(results) + ); } + //JM so looks like it's here looping through all of the search pages in one request, which could be a big load + // would be better to request the next page when the next page of results was clicked through to? if (results.next) { this.getSearchResults( results.next, diff --git a/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts b/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts index fc7e18376..50fbbf7ab 100644 --- a/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts +++ b/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts @@ -9,7 +9,7 @@ import { AnnotationRect } from "@iiif/manifold"; import { AnnotationResults } from "../uv-shared-module/AnnotationResults"; import { SearchHit } from "../uv-shared-module/SearchHit"; import { Keyboard, Strings } from "../../Utils"; -import * as KeyCodes from "../../KeyCodes"; +import * as KeyCodes from "@edsilv/key-codes"; import { URLAdapter } from "../../URLAdapter"; import { XYWHFragment } from "../uv-shared-module/XYWHFragment"; @@ -388,6 +388,7 @@ export class SearchLeftPanel extends LeftPanel { this.$searchResultContainer.html(""); this.$searchText.blur(); this.showSearchSpinner(); + //JM this triggers the same search function as previus version, defined in the OSD Extension.ts this.extensionHost.publish(OpenSeadragonExtensionEvents.SEARCH, this.terms); } @@ -466,10 +467,8 @@ export class SearchLeftPanel extends LeftPanel { ); div.append( - hitNumberSpan[0].outerHTML + - searchHit.before + - searchHitSpan[0].outerHTML + - searchHit.after + // hitNumberSpan[0].outerHTML + + searchHit.before + searchHitSpan[0].outerHTML + searchHit.after ); $(div).on("keydown", (e: any) => { const originalEvent: KeyboardEvent = e.originalEvent; From 77562b337b3b099a80d3d819e6fe176adf56dc45 Mon Sep 17 00:00:00 2001 From: jamesmisson Date: Thu, 30 Oct 2025 11:01:57 +0000 Subject: [PATCH 8/9] clean up comments --- .../extensions/uv-openseadragon-extension/Extension.ts | 10 ++++------ .../uv-searchleftpanel-module/SearchLeftPanel.ts | 1 - 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts index 593228e7d..3c1c28fdd 100644 --- a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts +++ b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts @@ -1029,8 +1029,6 @@ export default class OpenSeadragonExtension extends BaseExtension { return groupedSearchHits; } - //JM SNA uses the function above, groupSearchHitsByTarget, to process the response from content search 1. This function below does the same thing but for content search 2 - // groupSearchHitsByTarget currently sorts hits by canvas index only, so hits get out of order when there are multiple on a page. // this function uses xwyh to sort by position on canvas too, so results are in reading order (if language is top to bottom, left to right!!), so this should also be applied to groupSearchHitsByTarget sortWebAnnotationsSearchHits(searchResults: any): SearchHit[] { const groupedSearchHits: SearchHit[] = []; @@ -1806,13 +1804,13 @@ export default class OpenSeadragonExtension extends BaseExtension { .then((response) => response.json()) .then((results) => { if (results.resources && results.resources.length) { - // this works for content search api 1 + // content search api 1 searchResults = searchResults.concat( this.groupOpenAnnotationsByTarget(results) ); searchHits = searchHits.concat(this.groupSearchHitsByTarget(results)); } else if (results.items && results.items.length) { - //this will work for content search api 2 + // content search api 2 searchResults = searchResults.concat( this.groupWebAnnotationResultsByTarget(results) ); @@ -1821,8 +1819,8 @@ export default class OpenSeadragonExtension extends BaseExtension { ); } - //JM so looks like it's here looping through all of the search pages in one request, which could be a big load - // would be better to request the next page when the next page of results was clicked through to? + // it's here looping through all of the search pages in one request, which could be a big load + // It would be better to properly use pagination here if available if (results.next) { this.getSearchResults( results.next, diff --git a/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts b/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts index 50fbbf7ab..f46c2b736 100644 --- a/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts +++ b/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts @@ -388,7 +388,6 @@ export class SearchLeftPanel extends LeftPanel { this.$searchResultContainer.html(""); this.$searchText.blur(); this.showSearchSpinner(); - //JM this triggers the same search function as previus version, defined in the OSD Extension.ts this.extensionHost.publish(OpenSeadragonExtensionEvents.SEARCH, this.terms); } From 673b4705d7d0550483fd3ca96ed096dd11d6c9fc Mon Sep 17 00:00:00 2001 From: jamesmisson Date: Thu, 30 Oct 2025 11:03:54 +0000 Subject: [PATCH 9/9] enable search hit highlighting in text panel --- .../TextRightPanel.ts | 260 +++++++++--------- 1 file changed, 130 insertions(+), 130 deletions(-) diff --git a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts index c4cc45967..5620658d0 100644 --- a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts +++ b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts @@ -98,25 +98,25 @@ export class TextRightPanel extends RightPanel { this.$top.append(this.$copyButton); } - // function getIntersectionArea(rect1, rect2) { - // const xOverlap = Math.max( - // 0, - // Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - - // Math.max(rect1.x, rect2.x) - // ); - // const yOverlap = Math.max( - // 0, - // Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - - // Math.max(rect1.y, rect2.y) - // ); - // return xOverlap * yOverlap; - // } - - // function getIntersectionPercentage(rect1, rect2) { - // const intersectionArea = getIntersectionArea(rect1, rect2); - // const rect1Area = rect1.width * rect1.height; - // return (intersectionArea / rect1Area) * 100; - // } + function getIntersectionArea(rect1, rect2) { + const xOverlap = Math.max( + 0, + Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - + Math.max(rect1.x, rect2.x) + ); + const yOverlap = Math.max( + 0, + Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - + Math.max(rect1.y, rect2.y) + ); + return xOverlap * yOverlap; + } + + function getIntersectionPercentage(rect1, rect2) { + const intersectionArea = getIntersectionArea(rect1, rect2); + const rect1Area = rect1.width * rect1.height; + return (intersectionArea / rect1Area) * 100; + } this.extensionHost.on(Events.SEARCH_HIT_CHANGED, (e) => { // this reacts to a new search hit being selected and styles the elements (rect on the canvas, span in the full text) appropriately @@ -251,45 +251,45 @@ export class TextRightPanel extends RightPanel { } } - // const annotationRects = (this.extension) - // .getAnnotationRects() - // .filter((rect) => { - // return rect["canvasIndex"] == c.index; - // }); - - // annotationRects.forEach((annotationRect) => { - // const rect = { - // x: annotationRect.x, - // y: annotationRect.y, - // width: annotationRect.width, - // height: annotationRect.height, - // }; - - // $("div.lineAnnotationRect").each( - // (i: Number, lineAnnotationRect: any) => { - // const x = $(lineAnnotationRect).data("x"); - // const y = $(lineAnnotationRect).data("y"); - // const width = $(lineAnnotationRect).data("width"); - // const height = $(lineAnnotationRect).data("height"); - // const lineRect = { x: x, y: y, width: width, height: height }; - - // const p = getIntersectionPercentage(rect, lineRect); - // if (p > 50) { - // const lineElement = $( - // "div#" + $(lineAnnotationRect).attr("id") + ".lineAnnotation" - // ); - // if (lineElement[0]) { - // this.highlightSearchHit( - // lineElement[0], - // annotationRect.chars, - // annotationRect.index, - // annotationRect.canvasIndex - // ); - // } - // } - // } - // ); - // }); + const annotationRects = (this.extension) + .getAnnotationRects() + .filter((rect) => { + return rect["canvasIndex"] == c.index; + }); + + annotationRects.forEach((annotationRect) => { + const rect = { + x: annotationRect.x, + y: annotationRect.y, + width: annotationRect.width, + height: annotationRect.height, + }; + + $("div.lineAnnotationRect").each( + (i: Number, lineAnnotationRect: any) => { + const x = $(lineAnnotationRect).data("x"); + const y = $(lineAnnotationRect).data("y"); + const width = $(lineAnnotationRect).data("width"); + const height = $(lineAnnotationRect).data("height"); + const lineRect = { x: x, y: y, width: width, height: height }; + + const p = getIntersectionPercentage(rect, lineRect); + if (p > 50) { + const lineElement = $( + "div#" + $(lineAnnotationRect).attr("id") + ".lineAnnotation" + ); + if (lineElement[0]) { + this.highlightSearchHit( + lineElement[0], + annotationRect.chars, + annotationRect.index, + annotationRect.canvasIndex + ); + } + } + } + ); + }); if ( $( @@ -333,78 +333,78 @@ export class TextRightPanel extends RightPanel { this.$top.parent().addClass("textRightPanel"); } - // highlightSearchHit( - // element: Element, - // searchText: string, - // index: string | number, - // canvasIndex: string | number - // ): void { - // // traverse only text nodes - // const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, { - // acceptNode: function (node: Node): number { - // // skip text nodes that are already inside searchHitSpan elements - // const parent = node.parentElement; - // if (parent && parent.classList.contains("searchHitSpan")) { - // return NodeFilter.FILTER_REJECT; - // } - // return NodeFilter.FILTER_ACCEPT; - // }, - // }); - - // let currentNode: Node | null; - // const textNodes: Text[] = []; - - // // collect all valid text nodes - // while ((currentNode = walker.nextNode())) { - // textNodes.push(currentNode as Text); - // } - - // // find the first occurrence of search hit - // for (const textNode of textNodes) { - // const textContent = textNode.textContent || ""; - // const hitIndex = textContent.indexOf(searchText); - - // if (hitIndex !== -1) { - // // split the text node and wrap the match - // const beforeText = textContent.substring(0, hitIndex); - // const matchText = textContent.substring( - // hitIndex, - // hitIndex + searchText.length - // ); - // const afterText = textContent.substring(hitIndex + searchText.length); - - // // create highlight span - // const highlightSpan = document.createElement("span"); - // highlightSpan.className = "searchHitSpan"; - // highlightSpan.setAttribute("data-index", String(index)); - // highlightSpan.setAttribute("data-canvas-index", String(canvasIndex)); - // highlightSpan.textContent = matchText; - - // const parent = textNode.parentNode; - - // if (parent) { - // // replace original text node with the parts - // if (beforeText) { - // const beforeNode = document.createTextNode(beforeText); - // parent.insertBefore(beforeNode, textNode); - // } - - // parent.insertBefore(highlightSpan, textNode); - - // if (afterText) { - // const afterNode = document.createTextNode(afterText); - // parent.insertBefore(afterNode, textNode); - // } - - // // remove the original text node - // parent.removeChild(textNode); - // } - - // // stop after finding and wrapping the first occurrence - // break; - // } - // } - // } + highlightSearchHit( + element: Element, + searchText: string, + index: string | number, + canvasIndex: string | number + ): void { + // traverse only text nodes + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, { + acceptNode: function (node: Node): number { + // skip text nodes that are already inside searchHitSpan elements + const parent = node.parentElement; + if (parent && parent.classList.contains("searchHitSpan")) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let currentNode: Node | null; + const textNodes: Text[] = []; + + // collect all valid text nodes + while ((currentNode = walker.nextNode())) { + textNodes.push(currentNode as Text); + } + + // find the first occurrence of search hit + for (const textNode of textNodes) { + const textContent = textNode.textContent || ""; + const hitIndex = textContent.indexOf(searchText); + + if (hitIndex !== -1) { + // split the text node and wrap the match + const beforeText = textContent.substring(0, hitIndex); + const matchText = textContent.substring( + hitIndex, + hitIndex + searchText.length + ); + const afterText = textContent.substring(hitIndex + searchText.length); + + // create highlight span + const highlightSpan = document.createElement("span"); + highlightSpan.className = "searchHitSpan"; + highlightSpan.setAttribute("data-index", String(index)); + highlightSpan.setAttribute("data-canvas-index", String(canvasIndex)); + highlightSpan.textContent = matchText; + + const parent = textNode.parentNode; + + if (parent) { + // replace original text node with the parts + if (beforeText) { + const beforeNode = document.createTextNode(beforeText); + parent.insertBefore(beforeNode, textNode); + } + + parent.insertBefore(highlightSpan, textNode); + + if (afterText) { + const afterNode = document.createTextNode(afterText); + parent.insertBefore(afterNode, textNode); + } + + // remove the original text node + parent.removeChild(textNode); + } + + // stop after finding and wrapping the first occurrence + break; + } + } + } toggleFinish(): void { super.toggleFinish();